diff --git a/backend/src/main/java/com/bakdata/conquery/apiv1/forms/ExternalForm.java b/backend/src/main/java/com/bakdata/conquery/apiv1/forms/ExternalForm.java index cc5fc0cc8b..374c1f6123 100644 --- a/backend/src/main/java/com/bakdata/conquery/apiv1/forms/ExternalForm.java +++ b/backend/src/main/java/com/bakdata/conquery/apiv1/forms/ExternalForm.java @@ -86,7 +86,7 @@ public String getLocalizedTypeLabel() { // Form had no specific title set. Try localized lookup in FormConfig final Locale preferredLocale = I18n.LOCALE.get(); - final FormType frontendConfig = FormScanner.FRONTEND_FORM_CONFIGS.get(getFormType()); + final FormType frontendConfig = FormScanner.resolveFormType(getFormType()); if (frontendConfig == null) { return getSubType(); diff --git a/backend/src/main/java/com/bakdata/conquery/apiv1/forms/Form.java b/backend/src/main/java/com/bakdata/conquery/apiv1/forms/Form.java index 11bcd5f3e6..c9844ce5c4 100644 --- a/backend/src/main/java/com/bakdata/conquery/apiv1/forms/Form.java +++ b/backend/src/main/java/com/bakdata/conquery/apiv1/forms/Form.java @@ -8,7 +8,9 @@ import com.bakdata.conquery.models.auth.entities.Subject; import com.bakdata.conquery.models.auth.permissions.Ability; import com.bakdata.conquery.models.datasets.Dataset; +import com.bakdata.conquery.models.error.ConqueryError; import com.bakdata.conquery.models.forms.frontendconfiguration.FormScanner; +import com.bakdata.conquery.models.forms.frontendconfiguration.FormType; import com.bakdata.conquery.models.forms.managed.ManagedForm; import com.bakdata.conquery.models.query.visitor.QueryVisitor; import com.fasterxml.jackson.annotation.JsonIgnore; @@ -42,7 +44,13 @@ public String getFormType() { public void authorize(Subject subject, Dataset submittedDataset, @NonNull ClassToInstanceMap visitors, MetaStorage storage) { QueryDescription.super.authorize(subject, submittedDataset, visitors, storage); // Check if subject is allowed to create this form - subject.authorize(FormScanner.FRONTEND_FORM_CONFIGS.get(getFormType()), Ability.CREATE); + final FormType formType = FormScanner.resolveFormType(getFormType()); + + if (formType == null) { + throw new ConqueryError.ExecutionCreationErrorUnspecified(); + } + + subject.authorize(formType, Ability.CREATE); } diff --git a/backend/src/main/java/com/bakdata/conquery/apiv1/frontend/FrontendConfiguration.java b/backend/src/main/java/com/bakdata/conquery/apiv1/frontend/FrontendConfiguration.java index 8aa6ce070a..57fcfa27ce 100644 --- a/backend/src/main/java/com/bakdata/conquery/apiv1/frontend/FrontendConfiguration.java +++ b/backend/src/main/java/com/bakdata/conquery/apiv1/frontend/FrontendConfiguration.java @@ -1,6 +1,7 @@ package com.bakdata.conquery.apiv1.frontend; import java.net.URL; +import java.time.LocalDate; import com.bakdata.conquery.models.config.FrontendConfig; import com.bakdata.conquery.models.config.IdColumnConfig; @@ -19,6 +20,7 @@ public record FrontendConfiguration( FrontendConfig.CurrencyConfig currency, IdColumnConfig queryUpload, URL manualUrl, - String contactEmail + String contactEmail, + LocalDate observationPeriodStart ) { } diff --git a/backend/src/main/java/com/bakdata/conquery/apiv1/frontend/FrontendPreviewConfig.java b/backend/src/main/java/com/bakdata/conquery/apiv1/frontend/FrontendPreviewConfig.java index 6fe30b9994..9769140747 100644 --- a/backend/src/main/java/com/bakdata/conquery/apiv1/frontend/FrontendPreviewConfig.java +++ b/backend/src/main/java/com/bakdata/conquery/apiv1/frontend/FrontendPreviewConfig.java @@ -1,6 +1,5 @@ package com.bakdata.conquery.apiv1.frontend; -import java.time.LocalDate; import java.util.Collection; import java.util.List; @@ -19,8 +18,6 @@ public static class Labelled { private final String label; } - private final LocalDate observationPeriodMin; - private final Collection all; @JsonProperty("default") private final Collection defaultConnectors; diff --git a/backend/src/main/java/com/bakdata/conquery/apiv1/query/TableExportQuery.java b/backend/src/main/java/com/bakdata/conquery/apiv1/query/TableExportQuery.java index 8ab94340d9..993cee86fc 100644 --- a/backend/src/main/java/com/bakdata/conquery/apiv1/query/TableExportQuery.java +++ b/backend/src/main/java/com/bakdata/conquery/apiv1/query/TableExportQuery.java @@ -2,6 +2,7 @@ import java.time.LocalDate; import java.util.Arrays; +import java.util.Collection; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; @@ -143,17 +144,26 @@ public void resolve(QueryResolveContext context) { query.resolve(context); // First is dates, second is source id - AtomicInteger currentPosition = new AtomicInteger(2); + final AtomicInteger currentPosition = new AtomicInteger(2); final Map secondaryIdPositions = calculateSecondaryIdPositions(currentPosition); - positions = calculateColumnPositions(currentPosition, tables, secondaryIdPositions); + // We need to know if a column is a concept column so we can prioritize it if it is also a SecondaryId + final Set conceptColumns = tables.stream() + .map(CQConcept::getTables) + .flatMap(Collection::stream) + .map(CQTable::getConnector) + .map(Connector::getColumn) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); - resultInfos = createResultInfos(secondaryIdPositions); + positions = calculateColumnPositions(currentPosition, tables, secondaryIdPositions, conceptColumns); + + resultInfos = createResultInfos(secondaryIdPositions, conceptColumns); } private Map calculateSecondaryIdPositions(AtomicInteger currentPosition) { - Map secondaryIdPositions = new HashMap<>(); + final Map secondaryIdPositions = new HashMap<>(); // SecondaryIds are pulled to the front and grouped over all tables tables.stream() @@ -169,9 +179,10 @@ private Map calculateSecondaryIdPositions(Atomi return secondaryIdPositions; } - private static Map calculateColumnPositions(AtomicInteger currentPosition, List tables, Map secondaryIdPositions) { + private static Map calculateColumnPositions(AtomicInteger currentPosition, List tables, Map secondaryIdPositions, Set conceptColumns) { final Map positions = new HashMap<>(); + for (CQConcept concept : tables) { for (CQTable table : concept.getTables()) { @@ -188,7 +199,8 @@ private static Map calculateColumnPositions(AtomicInteger curre continue; } - if (column.getSecondaryId() != null) { + // We want to have ConceptColumns separate here. + if (column.getSecondaryId() != null && !conceptColumns.contains(column)) { positions.putIfAbsent(column, secondaryIdPositions.get(column.getSecondaryId())); continue; } @@ -201,7 +213,7 @@ private static Map calculateColumnPositions(AtomicInteger curre return positions; } - private List createResultInfos(Map secondaryIdPositions) { + private List createResultInfos(Map secondaryIdPositions, Set conceptColumns) { final int size = positions.values().stream().mapToInt(i -> i).max().getAsInt() + 1; @@ -252,7 +264,7 @@ private List createResultInfos(Map } // SecondaryIds and date columns are pulled to the front, thus already covered. - if (column.getSecondaryId() != null) { + if (column.getSecondaryId() != null && !conceptColumns.contains(column)) { infos[secondaryIdPositions.get(column.getSecondaryId())].getSemantics() .add(new SemanticType.ColumnT(column)); continue; @@ -303,12 +315,12 @@ public static String printValue(Concept concept, Object rawValue, PrintSettings final TreeConcept tree = (TreeConcept) concept; - int localId = (int) rawValue; + final int localId = (int) rawValue; final ConceptTreeNode node = tree.getElementByLocalId(localId); if (!printSettings.isPrettyPrint()) { - return node.getId().toStringWithoutDataset(); + return node.getId().toString(); } if (node.getDescription() == null) { diff --git a/backend/src/main/java/com/bakdata/conquery/apiv1/query/concept/filter/FilterValue.java b/backend/src/main/java/com/bakdata/conquery/apiv1/query/concept/filter/FilterValue.java index 681899eeed..f3d2a1de35 100644 --- a/backend/src/main/java/com/bakdata/conquery/apiv1/query/concept/filter/FilterValue.java +++ b/backend/src/main/java/com/bakdata/conquery/apiv1/query/concept/filter/FilterValue.java @@ -19,6 +19,7 @@ import com.bakdata.conquery.models.identifiable.ids.specific.FilterId; import com.bakdata.conquery.models.query.QueryResolveContext; import com.bakdata.conquery.models.query.queryplan.filter.FilterNode; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonTypeInfo; import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.core.JsonProcessingException; @@ -27,6 +28,7 @@ import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.fasterxml.jackson.databind.deser.std.StdDeserializer; import com.fasterxml.jackson.databind.exc.InvalidTypeIdException; +import io.dropwizard.validation.ValidationMethod; import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NoArgsConstructor; @@ -44,6 +46,10 @@ @EqualsAndHashCode @ToString(of = "value") public abstract class FilterValue { + /** + * Very large SELECT FilterValues can cause issues, so we just limit it to large but not gigantic quantities. + */ + private static final int MAX_NUMBER_FILTER_VALUES = 20_000; @NotNull @Nonnull @NsIdRef @@ -68,6 +74,12 @@ public static class CQMultiSelectFilter extends FilterValue { public CQMultiSelectFilter(@NsIdRef Filter filter, String[] value) { super(filter, value); } + + @ValidationMethod(message = "Too many values selected.") + @JsonIgnore + public boolean isSaneAmountOfFilterValues() { + return getValue().length < MAX_NUMBER_FILTER_VALUES; + } } @NoArgsConstructor @@ -77,6 +89,12 @@ public static class CQBigMultiSelectFilter extends FilterValue { public CQBigMultiSelectFilter(@NsIdRef Filter filter, String[] value) { super(filter, value); } + + @ValidationMethod(message = "Too many values selected.") + @JsonIgnore + public boolean isSaneAmountOfFilterValues() { + return getValue().length < MAX_NUMBER_FILTER_VALUES; + } } @NoArgsConstructor diff --git a/backend/src/main/java/com/bakdata/conquery/commands/ManagerNode.java b/backend/src/main/java/com/bakdata/conquery/commands/ManagerNode.java index 543e7723f6..7d7d2176ef 100644 --- a/backend/src/main/java/com/bakdata/conquery/commands/ManagerNode.java +++ b/backend/src/main/java/com/bakdata/conquery/commands/ManagerNode.java @@ -46,6 +46,7 @@ import com.bakdata.conquery.resources.unprotected.AuthServlet; import com.bakdata.conquery.tasks.PermissionCleanupTask; import com.bakdata.conquery.tasks.QueryCleanupTask; +import com.bakdata.conquery.tasks.ReloadMetaStorageTask; import com.bakdata.conquery.tasks.ReportConsistencyTask; import com.bakdata.conquery.util.io.ConqueryMDC; import com.fasterxml.jackson.databind.DeserializationConfig; @@ -194,8 +195,9 @@ public void run(ConqueryConfig config, Environment environment) throws Interrupt ))); environment.admin().addTask(new PermissionCleanupTask(storage)); environment.admin().addTask(new ReportConsistencyTask(datasetRegistry)); + environment.admin().addTask(new ReloadMetaStorageTask(storage)); - ShutdownTask shutdown = new ShutdownTask(); + final ShutdownTask shutdown = new ShutdownTask(); environment.admin().addTask(shutdown); environment.lifecycle().addServerLifecycleListener(shutdown); } diff --git a/backend/src/main/java/com/bakdata/conquery/commands/ShardNode.java b/backend/src/main/java/com/bakdata/conquery/commands/ShardNode.java index cde4efe2c8..a2bb9e62a2 100644 --- a/backend/src/main/java/com/bakdata/conquery/commands/ShardNode.java +++ b/backend/src/main/java/com/bakdata/conquery/commands/ShardNode.java @@ -314,23 +314,27 @@ private void reportJobManagerStatus() { // Collect the ShardNode and all its workers jobs into a single queue - final JobManagerStatus jobManagerStatus = jobManager.reportStatus(); for (Worker worker : workers.getWorkers().values()) { - jobManagerStatus.getJobs().addAll(worker.getJobManager().reportStatus().getJobs()); - } - + final JobManagerStatus jobManagerStatus = new JobManagerStatus( + null, worker.getInfo().getDataset(), + worker.getJobManager().getJobStatus() + ); - try { - context.trySend(new UpdateJobManagerStatus(jobManagerStatus)); - } - catch (Exception e) { - log.warn("Failed to report job manager status", e); + try { + context.trySend(new UpdateJobManagerStatus(jobManagerStatus)); + } + catch (Exception e) { + log.warn("Failed to report job manager status", e); - if (config.isFailOnError()) { - System.exit(1); + if (config.isFailOnError()) { + System.exit(1); + } } } + + + } public boolean isBusy() { diff --git a/backend/src/main/java/com/bakdata/conquery/io/jetty/NoSuchElementExceptionMapper.java b/backend/src/main/java/com/bakdata/conquery/io/jetty/NoSuchElementExceptionMapper.java index 531786b0c5..f751f9f31f 100644 --- a/backend/src/main/java/com/bakdata/conquery/io/jetty/NoSuchElementExceptionMapper.java +++ b/backend/src/main/java/com/bakdata/conquery/io/jetty/NoSuchElementExceptionMapper.java @@ -12,7 +12,7 @@ public class NoSuchElementExceptionMapper implements ExceptionMapper { @Override public Response toResponse(NoSuchElementException exception) { - log.trace("Mapping exception:", exception); + log.warn("Uncaught NoSuchElementException", exception); return Response.status(Response.Status.NOT_FOUND).type(MediaType.APPLICATION_JSON_TYPE).entity(exception.getMessage()).build(); } } diff --git a/backend/src/main/java/com/bakdata/conquery/models/config/FrontendConfig.java b/backend/src/main/java/com/bakdata/conquery/models/config/FrontendConfig.java index e50fb7008f..1bbfcddd56 100644 --- a/backend/src/main/java/com/bakdata/conquery/models/config/FrontendConfig.java +++ b/backend/src/main/java/com/bakdata/conquery/models/config/FrontendConfig.java @@ -6,31 +6,33 @@ import javax.annotation.Nullable; import javax.validation.Valid; import javax.validation.constraints.Email; +import javax.validation.constraints.Min; import javax.validation.constraints.NotNull; import com.bakdata.conquery.models.forms.frontendconfiguration.FormScanner; import com.fasterxml.jackson.annotation.JsonAlias; -import groovy.transform.ToString; import lombok.AllArgsConstructor; import lombok.Data; -import lombok.Getter; import lombok.NoArgsConstructor; -import lombok.Setter; import lombok.With; import lombok.extern.slf4j.Slf4j; -@ToString -@Getter -@Setter @NoArgsConstructor @AllArgsConstructor @Slf4j @With +@Data public class FrontendConfig { @Valid @NotNull private CurrencyConfig currency = new CurrencyConfig(); + /** + * Years to include in entity preview. + */ + @Min(0) + private int observationPeriodYears = 6; + /** * The url that points a manual. This is also used by the {@link FormScanner} * as the base url for forms that specify a relative url. Internally {@link URI#resolve(URI)} diff --git a/backend/src/main/java/com/bakdata/conquery/models/datasets/PreviewConfig.java b/backend/src/main/java/com/bakdata/conquery/models/datasets/PreviewConfig.java index c08a23458c..4d84e6150e 100644 --- a/backend/src/main/java/com/bakdata/conquery/models/datasets/PreviewConfig.java +++ b/backend/src/main/java/com/bakdata/conquery/models/datasets/PreviewConfig.java @@ -1,6 +1,6 @@ package com.bakdata.conquery.models.datasets; -import java.time.LocalDate; +import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Optional; @@ -46,11 +46,6 @@ @NoArgsConstructor public class PreviewConfig { - /** - * Default start-date for EntityPreview, end date will always be LocalDate.now() - */ - @NotNull - private LocalDate observationStart; /** * Selects to be used in {@link com.bakdata.conquery.apiv1.QueryProcessor#getSingleEntityExport(Subject, UriBuilder, String, String, List, Dataset, Range)}. @@ -120,17 +115,18 @@ public record InfoCardSelect(@NotNull String label, SelectId select, String desc * Defines a group of selects that will be evaluated per quarter and year in the requested period of the entity-preview. */ public record TimeStratifiedSelects(@NotNull String label, String description, @NotEmpty List selects){ - @ValidationMethod(message = "Selects may be referenced only once.") - @JsonIgnore - public boolean isSelectsUnique() { - return selects().stream().map(InfoCardSelect::select).distinct().count() == selects().size(); - } + } - @ValidationMethod(message = "Labels must be unique.") - @JsonIgnore - public boolean isLabelsUnique() { - return selects().stream().map(InfoCardSelect::label).distinct().count() == selects().size(); - } + @ValidationMethod(message = "Selects may be referenced only once.") + @JsonIgnore + public boolean isSelectsUnique() { + return timeStratifiedSelects.stream().map(TimeStratifiedSelects::selects).flatMap(Collection::stream).map(InfoCardSelect::select).distinct().count() == timeStratifiedSelects.stream().map(TimeStratifiedSelects::selects).flatMap(Collection::stream).count(); + } + + @ValidationMethod(message = "Labels must be unique.") + @JsonIgnore + public boolean isLabelsUnique() { + return timeStratifiedSelects.stream().map(TimeStratifiedSelects::selects).flatMap(Collection::stream).map(InfoCardSelect::label).distinct().count() == timeStratifiedSelects.stream().map(TimeStratifiedSelects::selects).flatMap(Collection::stream).count(); } @JsonIgnore diff --git a/backend/src/main/java/com/bakdata/conquery/models/datasets/concepts/FrontEndConceptBuilder.java b/backend/src/main/java/com/bakdata/conquery/models/datasets/concepts/FrontEndConceptBuilder.java index c88586659b..fee4d75bda 100644 --- a/backend/src/main/java/com/bakdata/conquery/models/datasets/concepts/FrontEndConceptBuilder.java +++ b/backend/src/main/java/com/bakdata/conquery/models/datasets/concepts/FrontEndConceptBuilder.java @@ -185,12 +185,11 @@ public static FrontendTable createTable(Connector con) { .collect(Collectors.toSet())) .build(); - if (con.getValidityDates().size() > 1) { - result.setDateColumn(new FrontendValidityDate(con.getValidityDatesDescription(), null, con.getValidityDates() - .stream() - .map(vd -> new FrontendValue(vd.getId() - .toString(), vd.getLabel())) - .collect(Collectors.toList()))); + if (!con.getValidityDates().isEmpty()) { + result.setDateColumn(new FrontendValidityDate(con.getValidityDatesDescription(), null, + con.getValidityDates().stream() + .map(vd -> new FrontendValue(vd.getId().toString(), vd.getLabel())) + .collect(Collectors.toList()))); if (!result.getDateColumn().getOptions().isEmpty()) { result.getDateColumn().setDefaultValue(result.getDateColumn().getOptions().get(0).getValue()); diff --git a/backend/src/main/java/com/bakdata/conquery/models/forms/frontendconfiguration/FormProcessor.java b/backend/src/main/java/com/bakdata/conquery/models/forms/frontendconfiguration/FormProcessor.java index 1a202425aa..df03966ba2 100644 --- a/backend/src/main/java/com/bakdata/conquery/models/forms/frontendconfiguration/FormProcessor.java +++ b/backend/src/main/java/com/bakdata/conquery/models/forms/frontendconfiguration/FormProcessor.java @@ -1,7 +1,5 @@ package com.bakdata.conquery.models.forms.frontendconfiguration; -import static com.bakdata.conquery.models.forms.frontendconfiguration.FormScanner.FRONTEND_FORM_CONFIGS; - import java.util.ArrayList; import java.util.Collection; import java.util.List; @@ -15,7 +13,7 @@ public class FormProcessor { public Collection getFormsForUser(Subject subject) { List allowedForms = new ArrayList<>(); - for (FormType formMapping : FRONTEND_FORM_CONFIGS.values()) { + for (FormType formMapping : FormScanner.getAllFormTypes()) { if (!subject.isPermitted(formMapping, Ability.CREATE)) { continue; } diff --git a/backend/src/main/java/com/bakdata/conquery/models/forms/frontendconfiguration/FormScanner.java b/backend/src/main/java/com/bakdata/conquery/models/forms/frontendconfiguration/FormScanner.java index a5ca29059f..c6f2ffc4e0 100644 --- a/backend/src/main/java/com/bakdata/conquery/models/forms/frontendconfiguration/FormScanner.java +++ b/backend/src/main/java/com/bakdata/conquery/models/forms/frontendconfiguration/FormScanner.java @@ -9,8 +9,11 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.function.Consumer; +import javax.annotation.Nullable; + import com.bakdata.conquery.apiv1.forms.Form; import com.bakdata.conquery.io.cps.CPSType; import com.bakdata.conquery.io.cps.CPSTypeIdResolver; @@ -36,15 +39,13 @@ public class FormScanner extends Task { public static final String MANUAL_URL_KEY = "manualUrl"; public static Map FRONTEND_FORM_CONFIGS = Collections.emptyMap(); - - private Consumer> providerChain = QueryUtils.getNoOpEntryPoint(); - /** * The config is used to look up the base url for manuals see {@link FrontendConfig#getManualUrl()}. * If the url was changed (e.g. using {@link AdminProcessor#executeScript(String)}) an execution of this * task accounts the change. */ private final ConqueryConfig config; + private Consumer> providerChain = QueryUtils.getNoOpEntryPoint(); public FormScanner(ConqueryConfig config) { super("form-scanner"); @@ -52,68 +53,50 @@ public FormScanner(ConqueryConfig config) { registerFrontendFormConfigProvider(ResourceFormConfigProvider::accept); } - private static Map> findBackendMappingClasses() { - Builder> backendClasses = ImmutableMap.builder(); - // Gather form implementations first - for (Class subclass : CPSTypeIdResolver.SCAN_RESULT.getSubclasses(Form.class.getName()).loadClasses()) { - if (Modifier.isAbstract(subclass.getModifiers())) { - continue; - } - CPSType[] cpsAnnotations = subclass.getAnnotationsByType(CPSType.class); - - if (cpsAnnotations.length == 0) { - log.warn("Implemented Form {} has no CPSType annotation", subclass); - continue; - } - for (CPSType cpsType : cpsAnnotations) { - backendClasses.put(cpsType.id(), (Class) subclass); - } - } - return backendClasses.build(); + public synchronized void registerFrontendFormConfigProvider(Consumer> provider) { + providerChain = providerChain.andThen(provider); } - public synchronized void registerFrontendFormConfigProvider(Consumer> provider){ - providerChain = providerChain.andThen(provider); + + @Nullable + public static FormType resolveFormType(String formType) { + return FRONTEND_FORM_CONFIGS.get(formType); } - /** - * Frontend form configurations can be provided from different sources. - * Each source must register a provider with {@link FormScanner#registerFrontendFormConfigProvider(Consumer)} beforehand. - */ - @SneakyThrows - private List findFrontendFormConfigs() { + public static Set getAllFormTypes() { + return Set.copyOf(FRONTEND_FORM_CONFIGS.values()); + } - ImmutableList.Builder frontendConfigs = ImmutableList.builder(); - try { - providerChain.accept(frontendConfigs); - } catch (Exception e) { - log.error("Unable to collect all frontend form configurations.", e); - } - return frontendConfigs.build(); + @Override + public void execute(Map> parameters, PrintWriter output) throws Exception { + FRONTEND_FORM_CONFIGS = generateFEFormConfigMap(); } private Map generateFEFormConfigMap() { // Collect backend implementations for specific forms - Map> forms = findBackendMappingClasses(); + final Map> forms = findBackendMappingClasses(); // Collect frontend form configurations for the specific forms - List frontendConfigs = findFrontendFormConfigs(); + final List frontendConfigs = findFrontendFormConfigs(); // Match frontend form configurations to backend implementations final ImmutableMap.Builder result = ImmutableMap.builderWithExpectedSize(frontendConfigs.size()); for (FormFrontendConfigInformation configInfo : frontendConfigs) { - ObjectNode configTree = configInfo.getConfigTree(); - JsonNode type = configTree.get("type"); + + final ObjectNode configTree = configInfo.getConfigTree(); + final JsonNode type = configTree.get("type"); + if (!validTypeId(type)) { log.warn("Found invalid type id in {}. Was: {}", configInfo.getOrigin(), type); continue; } // Extract complete type information (type@subtype) and type information - String fullTypeIdentifier = type.asText(); - String typeIdentifier = CPSTypeIdResolver.truncateSubTypeInformation(fullTypeIdentifier); + final String fullTypeIdentifier = type.asText(); + final String typeIdentifier = CPSTypeIdResolver.truncateSubTypeInformation(fullTypeIdentifier); + if (!forms.containsKey(typeIdentifier)) { log.error("Frontend form config {} (type = {}) does not map to a backend class.", configInfo, type); continue; @@ -139,14 +122,19 @@ private Map generateFEFormConfigMap() { return URI.create(manualUrl.textValue()); }); + + final URL manualBaseUrl = config.getFrontend().getManualUrl(); + if (manualBaseUrl != null && manualURL != null) { final TextNode manualNode = relativizeManualUrl(fullTypeIdentifier, manualURL, manualBaseUrl); + if (manualNode == null) { log.warn("Manual url relativization did not succeed for {}. Skipping registration.", fullTypeIdentifier); continue; } + configTree.set(MANUAL_URL_KEY, manualNode); } @@ -158,6 +146,50 @@ private Map generateFEFormConfigMap() { return result.build(); } + private static Map> findBackendMappingClasses() { + final Builder> backendClasses = ImmutableMap.builder(); + // Gather form implementations first + for (Class subclass : CPSTypeIdResolver.SCAN_RESULT.getSubclasses(Form.class.getName()).loadClasses()) { + if (Modifier.isAbstract(subclass.getModifiers())) { + continue; + } + + final CPSType[] cpsAnnotations = subclass.getAnnotationsByType(CPSType.class); + + if (cpsAnnotations.length == 0) { + log.warn("Implemented Form {} has no CPSType annotation", subclass); + continue; + } + + for (CPSType cpsType : cpsAnnotations) { + backendClasses.put(cpsType.id(), (Class) subclass); + } + } + return backendClasses.build(); + } + + /** + * Frontend form configurations can be provided from different sources. + * Each source must register a provider with {@link FormScanner#registerFrontendFormConfigProvider(Consumer)} beforehand. + */ + @SneakyThrows + private List findFrontendFormConfigs() { + + final ImmutableList.Builder frontendConfigs = ImmutableList.builder(); + + try { + providerChain.accept(frontendConfigs); + } + catch (Exception e) { + log.error("Unable to collect all frontend form configurations.", e); + } + return frontendConfigs.build(); + } + + private static boolean validTypeId(JsonNode node) { + return node != null && node.isTextual() && !node.asText().isEmpty(); + } + private TextNode relativizeManualUrl(@NonNull String formTypeIdentifier, @NonNull URI manualUri, @NonNull URL manualBaseUrl) { try { @@ -182,13 +214,4 @@ private TextNode relativizeManualUrl(@NonNull String formTypeIdentifier, @NonNul } } - private static boolean validTypeId(JsonNode node) { - return node != null && node.isTextual() && !node.asText().isEmpty(); - } - - @Override - public void execute(Map> parameters, PrintWriter output) throws Exception { - FRONTEND_FORM_CONFIGS = generateFEFormConfigMap(); - } - } diff --git a/backend/src/main/java/com/bakdata/conquery/models/jobs/ImportJob.java b/backend/src/main/java/com/bakdata/conquery/models/jobs/ImportJob.java index d02838e864..3e9aa63cbe 100644 --- a/backend/src/main/java/com/bakdata/conquery/models/jobs/ImportJob.java +++ b/backend/src/main/java/com/bakdata/conquery/models/jobs/ImportJob.java @@ -350,7 +350,7 @@ private Map> sendBuckets(Map starts, M private void awaitFreeJobQueue(WorkerInformation responsibleWorker) { try { - responsibleWorker.getConnectedShardNode().waitForFreeJobqueue(); + responsibleWorker.getConnectedShardNode().waitForFreeJobQueue(); } catch (InterruptedException e) { log.error("Interrupted while waiting for worker[{}] to have free space in queue", responsibleWorker, e); diff --git a/backend/src/main/java/com/bakdata/conquery/models/jobs/JobManager.java b/backend/src/main/java/com/bakdata/conquery/models/jobs/JobManager.java index 1783db1ae2..b9be29b718 100644 --- a/backend/src/main/java/com/bakdata/conquery/models/jobs/JobManager.java +++ b/backend/src/main/java/com/bakdata/conquery/models/jobs/JobManager.java @@ -38,14 +38,12 @@ public void addFastJob(Job job) { fastExecutor.add(job); } - public JobManagerStatus reportStatus() { - - return new JobManagerStatus( - getSlowJobs() - .stream() - .map(job -> new JobStatus(job.getJobId(), job.getProgressReporter().getProgress(), job.getLabel(), job.isCancelled())) - .collect(Collectors.toList()) - ); + public List getJobStatus() { + return getSlowJobs().stream() + .map(job -> new JobStatus(job.getJobId(), job.getProgressReporter().getProgress(), job.getLabel(), job.isCancelled())) + .sorted() + .collect(Collectors.toList()); + } public List getSlowJobs() { diff --git a/backend/src/main/java/com/bakdata/conquery/models/jobs/JobManagerStatus.java b/backend/src/main/java/com/bakdata/conquery/models/jobs/JobManagerStatus.java index b2524f9b01..ec2397604c 100644 --- a/backend/src/main/java/com/bakdata/conquery/models/jobs/JobManagerStatus.java +++ b/backend/src/main/java/com/bakdata/conquery/models/jobs/JobManagerStatus.java @@ -2,30 +2,38 @@ import java.time.Duration; import java.time.LocalDateTime; -import java.util.Collection; -import java.util.SortedSet; -import java.util.TreeSet; +import java.util.List; +import javax.annotation.Nullable; import javax.validation.constraints.NotNull; +import com.bakdata.conquery.models.identifiable.ids.specific.DatasetId; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.AllArgsConstructor; import lombok.Data; -import lombok.NonNull; -import lombok.RequiredArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.With; +import org.apache.commons.lang3.time.DurationFormatUtils; @Data -@RequiredArgsConstructor(onConstructor_ = @JsonCreator) +@AllArgsConstructor(onConstructor_ = @JsonCreator) public class JobManagerStatus { - @NonNull + @With + @Nullable + private final String origin; + @Nullable + private final DatasetId dataset; @NotNull - private final LocalDateTime timestamp = LocalDateTime.now(); + @EqualsAndHashCode.Exclude + private final LocalDateTime timestamp; @NotNull - private final SortedSet jobs = new TreeSet<>(); + @EqualsAndHashCode.Exclude + private final List jobs; - public JobManagerStatus(Collection jobs) { - this.jobs.addAll(jobs); + public JobManagerStatus(String origin, DatasetId dataset, List statuses) { + this(origin, dataset, LocalDateTime.now(), statuses); } public int size() { @@ -35,11 +43,8 @@ public int size() { // Used in AdminUIResource/jobs @JsonIgnore public String getAgeString() { - Duration duration = Duration.between(timestamp, LocalDateTime.now()); + final Duration duration = Duration.between(timestamp, LocalDateTime.now()); - if (duration.toSeconds() > 0) { - return Long.toString(duration.toSeconds()) + " s"; - } - return Long.toString(duration.toMillis()) + " ms"; + return DurationFormatUtils.formatDurationWords(duration.toMillis(), true, true); } } diff --git a/backend/src/main/java/com/bakdata/conquery/models/messages/network/specific/UpdateJobManagerStatus.java b/backend/src/main/java/com/bakdata/conquery/models/messages/network/specific/UpdateJobManagerStatus.java index 6668b6d446..3f2c339ecf 100644 --- a/backend/src/main/java/com/bakdata/conquery/models/messages/network/specific/UpdateJobManagerStatus.java +++ b/backend/src/main/java/com/bakdata/conquery/models/messages/network/specific/UpdateJobManagerStatus.java @@ -8,31 +8,28 @@ import com.bakdata.conquery.models.messages.network.NetworkMessage; import com.bakdata.conquery.models.messages.network.NetworkMessageContext.ManagerNodeNetworkContext; import com.bakdata.conquery.models.worker.ShardNodeInformation; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; -import lombok.ToString; +import com.fasterxml.jackson.annotation.JsonCreator; +import lombok.Data; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -@CPSType(id="UPDATE_JOB_MANAGER_STATUS", base=NetworkMessage.class) -@NoArgsConstructor @AllArgsConstructor @Getter @Setter @ToString(of = "status") +@CPSType(id = "UPDATE_JOB_MANAGER_STATUS", base = NetworkMessage.class) @Slf4j +@Data +@RequiredArgsConstructor(onConstructor_ = {@JsonCreator}) public class UpdateJobManagerStatus extends MessageToManagerNode { @NotNull - private JobManagerStatus status; + private final JobManagerStatus status; @Override public void react(ManagerNodeNetworkContext context) throws Exception { - ShardNodeInformation node = context.getNamespaces() - .getShardNodes() - .get(context.getRemoteAddress()); + final ShardNodeInformation node = context.getNamespaces().getShardNodes().get(context.getRemoteAddress()); if (node == null) { - log.error("Could not find ShardNode {}, I only know of {}", context.getRemoteAddress(), context.getNamespaces().getShardNodes().keySet()); - } - else { - node.setJobManagerStatus(status); + log.error("Could not find ShardNode `{}`, I only know of {}", context.getRemoteAddress(), context.getNamespaces().getShardNodes().keySet()); + return; } + // The shards don't know their own name so we attach it here + node.addJobManagerStatus(status.withOrigin(context.getRemoteAddress().toString())); } } diff --git a/backend/src/main/java/com/bakdata/conquery/models/query/ExecutionManager.java b/backend/src/main/java/com/bakdata/conquery/models/query/ExecutionManager.java index ea2e844612..933fe3daab 100644 --- a/backend/src/main/java/com/bakdata/conquery/models/query/ExecutionManager.java +++ b/backend/src/main/java/com/bakdata/conquery/models/query/ExecutionManager.java @@ -14,6 +14,7 @@ import com.bakdata.conquery.models.auth.entities.User; import com.bakdata.conquery.models.config.ConqueryConfig; import com.bakdata.conquery.models.datasets.Dataset; +import com.bakdata.conquery.models.error.ConqueryError; import com.bakdata.conquery.models.execution.ExecutionState; import com.bakdata.conquery.models.execution.InternalExecution; import com.bakdata.conquery.models.execution.ManagedExecution; @@ -34,13 +35,6 @@ public class ExecutionManager { private final MetaStorage storage; - private final Cache>> executionResults = - CacheBuilder.newBuilder() - .softValues() - .removalListener(this::executionRemoved) - .build(); - - /** * Manage state of evicted Queries, setting them to NEW. */ @@ -56,7 +50,11 @@ private void executionRemoved(RemovalNotification> r log.warn("Evicted Results for Query[{}] (Reason: {})", executionId, removalNotification.getCause()); storage.getExecution(executionId).reset(); - } + } private final Cache>> executionResults = + CacheBuilder.newBuilder() + .softValues() + .removalListener(this::executionRemoved) + .build(); public ManagedExecution runQuery(Namespace namespace, QueryDescription query, User user, Dataset submittedDataset, ConqueryConfig config, boolean system) { final ManagedExecution execution = createExecution(query, user, submittedDataset, system); @@ -70,14 +68,18 @@ public ManagedExecution createExecution(QueryDescription query, User user, Datas } public void execute(Namespace namespace, ManagedExecution execution, ConqueryConfig config) { - // Initialize the query / create subqueries try { execution.initExecutable(namespace, config); } catch (Exception e) { - log.error("Failed to initialize Query[{}]", execution.getId(), e); + // ConqueryErrors are usually user input errors so no need to log them at level=ERROR + if (e instanceof ConqueryError) { + log.warn("Failed to initialize Query[{}]", execution.getId(), e); + } + else { + log.error("Failed to initialize Query[{}]", execution.getId(), e); + } - //TODO we don't want to store completely faulty queries but is that right like this? storage.removeExecution(execution.getId()); throw e; } @@ -96,7 +98,6 @@ public void execute(Namespace namespace, ManagedExecution execution, ConqueryCon } } - public ManagedExecution createQuery(QueryDescription query, UUID queryId, User user, Dataset submittedDataset, boolean system) { // Transform the submitted query into an initialized execution ManagedExecution managed = query.toManagedExecution(user, submittedDataset, storage); diff --git a/backend/src/main/java/com/bakdata/conquery/models/worker/ShardNodeInformation.java b/backend/src/main/java/com/bakdata/conquery/models/worker/ShardNodeInformation.java index ff1b27d619..e369c9819b 100644 --- a/backend/src/main/java/com/bakdata/conquery/models/worker/ShardNodeInformation.java +++ b/backend/src/main/java/com/bakdata/conquery/models/worker/ShardNodeInformation.java @@ -2,6 +2,8 @@ import java.time.LocalDateTime; import java.time.temporal.ChronoUnit; +import java.util.HashSet; +import java.util.Set; import com.bakdata.conquery.io.mina.MessageSender; import com.bakdata.conquery.io.mina.NetworkSession; @@ -20,7 +22,11 @@ public class ShardNodeInformation extends MessageSender.Simple jobManagerStatus = new HashSet<>(); - /** - * Used to await/notify for full job-queues. - */ - @JsonIgnore - private final transient Object jobManagerSync = new Object(); + private LocalDateTime lastStatusTime = LocalDateTime.now(); public ShardNodeInformation(NetworkSession session, int backpressure) { super(session); @@ -55,7 +57,11 @@ private String getLatenessMetricName() { * Calculate the time in Milliseconds since we last received a {@link JobManagerStatus} from the corresponding shard. */ private long getMillisSinceLastStatus() { - return getJobManagerStatus().getTimestamp().until(LocalDateTime.now(), ChronoUnit.MILLIS); + if(getJobManagerStatus().isEmpty()){ + return -1; + } + + return lastStatusTime.until(LocalDateTime.now(), ChronoUnit.MILLIS); } @Override @@ -65,17 +71,32 @@ public void awaitClose() { SharedMetricRegistries.getDefault().remove(getLatenessMetricName()); } - public void setJobManagerStatus(JobManagerStatus status) { - jobManagerStatus = status; - if (status.size() < backpressure) { + public long calculatePressure() { + return jobManagerStatus.stream().mapToLong(status -> status.getJobs().size()).sum(); + } + + public void addJobManagerStatus(JobManagerStatus incoming) { + lastStatusTime = LocalDateTime.now(); + + synchronized (jobManagerStatus) { + // replace with new status + jobManagerStatus.remove(incoming); + jobManagerStatus.add(incoming); + } + + if (calculatePressure() < backpressure) { synchronized (jobManagerSync) { jobManagerSync.notifyAll(); } } } - public void waitForFreeJobqueue() throws InterruptedException { - if (jobManagerStatus.size() >= backpressure) { + public void waitForFreeJobQueue() throws InterruptedException { + if (jobManagerStatus.isEmpty()) { + return; + } + + if (calculatePressure() >= backpressure) { log.trace("Have to wait for free JobQueue (size = {})", jobManagerStatus.size()); synchronized (jobManagerSync) { jobManagerSync.wait(); diff --git a/backend/src/main/java/com/bakdata/conquery/resources/admin/ShutdownTask.java b/backend/src/main/java/com/bakdata/conquery/resources/admin/ShutdownTask.java index e87cff242b..0965112c78 100644 --- a/backend/src/main/java/com/bakdata/conquery/resources/admin/ShutdownTask.java +++ b/backend/src/main/java/com/bakdata/conquery/resources/admin/ShutdownTask.java @@ -25,24 +25,25 @@ public void serverStarted(Server server) { @Override public void execute(Map> parameters, PrintWriter output) throws Exception { + log.info("Received Shutdown command"); + if(server == null) { output.print("Server not yet started"); + return; } - else { - output.print("Shutting down"); - log.info("Received Shutdown command"); - //this must be done in an extra step or the shutdown will wait for this request to be resolved - new Thread("shutdown waiter thread") { - @Override - public void run() { - try { - server.stop(); - } catch (Exception e) { - log.error("Failed while shutting down", e); - } + + output.print("Shutting down"); + //this must be done in an extra step or the shutdown will wait for this request to be resolved + new Thread("shutdown waiter thread") { + @Override + public void run() { + try { + server.stop(); + } catch (Exception e) { + log.error("Failed while shutting down", e); } - }.start(); - } + } + }.start(); } } diff --git a/backend/src/main/java/com/bakdata/conquery/resources/admin/rest/AdminProcessor.java b/backend/src/main/java/com/bakdata/conquery/resources/admin/rest/AdminProcessor.java index 2aa0277602..76261723dd 100644 --- a/backend/src/main/java/com/bakdata/conquery/resources/admin/rest/AdminProcessor.java +++ b/backend/src/main/java/com/bakdata/conquery/resources/admin/rest/AdminProcessor.java @@ -4,7 +4,6 @@ import java.util.ArrayList; import java.util.Collection; import java.util.List; -import java.util.Objects; import java.util.SortedSet; import java.util.TreeSet; import java.util.concurrent.ScheduledExecutorService; @@ -28,10 +27,10 @@ import com.bakdata.conquery.models.jobs.JobManager; import com.bakdata.conquery.models.jobs.JobManagerStatus; import com.bakdata.conquery.models.worker.DatasetRegistry; +import com.bakdata.conquery.models.worker.Namespace; import com.bakdata.conquery.models.worker.ShardNodeInformation; import com.bakdata.conquery.util.ConqueryEscape; import com.fasterxml.jackson.databind.ObjectWriter; -import com.google.common.collect.ImmutableMap; import com.google.common.collect.Multimap; import com.univocity.parsers.csv.CsvWriter; import groovy.lang.GroovyShell; @@ -58,14 +57,6 @@ public class AdminProcessor { private final Validator validator; private final ObjectWriter jsonWriter = Jackson.MAPPER.writer(); - - - public synchronized void addRole(Role role) throws JSONException { - ValidatorHelper.failOnError(log, validator.validate(role)); - log.trace("New role:\tLabel: {}\tName: {}\tId: {} ", role.getLabel(), role.getName(), role.getId()); - storage.addRole(role); - } - public void addRoles(List roles) { for (Role role : roles) { @@ -78,6 +69,12 @@ public void addRoles(List roles) { } } + public synchronized void addRole(Role role) throws JSONException { + ValidatorHelper.failOnError(log, validator.validate(role)); + log.trace("New role:\tLabel: {}\tName: {}\tId: {} ", role.getLabel(), role.getName(), role.getId()); + storage.addRole(role); + } + /** * Deletes the mandator, that is identified by the id. Its references are * removed from the users, the groups, and from the storage. @@ -106,7 +103,7 @@ public SortedSet getAllRoles() { /** * Handles creation of permissions. * - * @param owner to which the permission is assigned + * @param owner to which the permission is assigned * @param permission The permission to create. * @throws JSONException is thrown upon processing JSONs. */ @@ -117,8 +114,7 @@ public void createPermission(PermissionOwner owner, ConqueryPermission permis /** * Handles deletion of permissions. * - * - * @param owner the owner of the permission + * @param owner the owner of the permission * @param permission The permission to delete. */ public void deletePermission(PermissionOwner owner, ConqueryPermission permission) { @@ -138,11 +134,6 @@ public synchronized void deleteUser(User user) { log.trace("Removed user {} from the storage.", user.getId()); } - public void addUser(User user) { - storage.addUser(user); - log.trace("New user:\tLabel: {}\tName: {}\tId: {} ", user.getLabel(), user.getName(), user.getId()); - } - public void addUsers(List users) { for (User user : users) { @@ -155,15 +146,13 @@ public void addUsers(List users) { } } - public TreeSet getAllGroups() { - return new TreeSet<>(storage.getAllGroups()); + public void addUser(User user) { + storage.addUser(user); + log.trace("New user:\tLabel: {}\tName: {}\tId: {} ", user.getLabel(), user.getName(), user.getId()); } - public synchronized void addGroup(Group group) throws JSONException { - ValidatorHelper.failOnError(log, validator.validate(group)); - storage.addGroup(group); - log.trace("New group:\tLabel: {}\tName: {}\tId: {} ", group.getLabel(), group.getName(), group.getId()); - + public TreeSet getAllGroups() { + return new TreeSet<>(storage.getAllGroups()); } public void addGroups(List groups) { @@ -178,6 +167,13 @@ public void addGroups(List groups) { } } + public synchronized void addGroup(Group group) throws JSONException { + ValidatorHelper.failOnError(log, validator.validate(group)); + storage.addGroup(group); + log.trace("New group:\tLabel: {}\tName: {}\tId: {} ", group.getLabel(), group.getName(), group.getId()); + + } + public void addUserToGroup(Group group, User user) { group.addMember(user); log.trace("Added user {} to group {}", user, group); @@ -193,12 +189,12 @@ public void deleteGroup(Group group) { log.trace("Removed group {}", group); } - public void deleteRoleFrom(RoleOwner owner, Role role) { + public void deleteRoleFrom(RoleOwner owner, Role role) { owner.removeRole(role); log.trace("Removed role {} from {}", role, owner); } - public void addRoleTo(RoleOwner owner, Role role) { + public void addRoleTo(RoleOwner owner, Role role) { owner.addRole(role); log.trace("Added role {} to {}", role, owner); } @@ -210,23 +206,15 @@ public String getPermissionOverviewAsCSV() { return getPermissionOverviewAsCSV(storage.getAllUsers()); } - - /** - * Renders the permission overview for all users in a certain {@link Group} in form of a CSV. - */ - public String getPermissionOverviewAsCSV(Group group) { - return getPermissionOverviewAsCSV(group.getMembers().stream().map(storage::getUser).collect(Collectors.toList())); - } - /** * Renders the permission overview for certain {@link User} in form of a CSV. */ public String getPermissionOverviewAsCSV(Collection users) { - StringWriter sWriter = new StringWriter(); - CsvWriter writer = config.getCsv().createWriter(sWriter); - List scope = config - .getAuthorizationRealms() - .getOverviewScope(); + final StringWriter sWriter = new StringWriter(); + final CsvWriter writer = config.getCsv().createWriter(sWriter); + final List scope = config + .getAuthorizationRealms() + .getOverviewScope(); // Header writeAuthOverviewHeader(writer, scope); // Body @@ -240,7 +228,7 @@ public String getPermissionOverviewAsCSV(Collection users) { * Writes the header of the CSV auth overview to the specified writer. */ private static void writeAuthOverviewHeader(CsvWriter writer, List scope) { - List headers = new ArrayList<>(); + final List headers = new ArrayList<>(); headers.add("User"); headers.addAll(scope); writer.writeHeaders(headers); @@ -254,7 +242,7 @@ private static void writeAuthOverviewUser(CsvWriter writer, List scope, writer.addValue(String.format("%s %s", user.getLabel(), ConqueryEscape.unescape(user.getName()))); // Print the permission per domain in the remaining columns - Multimap permissions = AuthorizationHelper.getEffectiveUserPermissions(user, scope, storage); + final Multimap permissions = AuthorizationHelper.getEffectiveUserPermissions(user, scope, storage); for (String domain : scope) { writer.addValue(permissions.get(domain).stream() .map(Object::toString) @@ -262,41 +250,45 @@ private static void writeAuthOverviewUser(CsvWriter writer, List scope, } writer.writeValuesToRow(); } - public ImmutableMap getJobs() { - return ImmutableMap.builder() - .put("ManagerNode", getJobManager().reportStatus()) - // Namespace JobManagers on ManagerNode - .putAll( - getDatasetRegistry().getDatasets().stream() - .collect(Collectors.toMap( - ns -> String.format("ManagerNode::%s", ns.getDataset().getId()), - ns -> ns.getJobManager().reportStatus() - ))) - // Remote Worker JobManagers - .putAll( - getDatasetRegistry() - .getShardNodes() - .values() - .stream() - .collect(Collectors.toMap( - si -> Objects.toString(si.getRemoteAddress()), - ShardNodeInformation::getJobManagerStatus - )) - ) - .build(); + + /** + * Renders the permission overview for all users in a certain {@link Group} in form of a CSV. + */ + public String getPermissionOverviewAsCSV(Group group) { + return getPermissionOverviewAsCSV(group.getMembers().stream().map(storage::getUser).collect(Collectors.toList())); } public boolean isBusy() { //Note that this does not and cannot check for fast jobs! - return getJobs().values().stream() + return getJobs().stream() .map(JobManagerStatus::getJobs) .anyMatch(Predicate.not(Collection::isEmpty)); } + public Collection getJobs() { + final List out = new ArrayList<>(); + + out.add(new JobManagerStatus("Manager", null, getJobManager().getJobStatus())); + + for (Namespace namespace : getDatasetRegistry().getDatasets()) { + out.add(new JobManagerStatus( + "Manager", namespace.getDataset().getId(), + namespace.getJobManager().getJobStatus() + )); + } + + for (ShardNodeInformation si : getDatasetRegistry().getShardNodes().values()) { + out.addAll(si.getJobManagerStatus()); + } + + return out; + } public Object executeScript(String script) { - CompilerConfiguration config = new CompilerConfiguration(); - GroovyShell groovy = new GroovyShell(config); + + final CompilerConfiguration config = new CompilerConfiguration(); + final GroovyShell groovy = new GroovyShell(config); + groovy.setProperty("datasetRegistry", getDatasetRegistry()); groovy.setProperty("jobManager", getJobManager()); groovy.setProperty("config", getConfig()); @@ -305,7 +297,7 @@ public Object executeScript(String script) { try { return groovy.evaluate(script); } - catch(Exception e) { + catch (Exception e) { return ExceptionUtils.getStackTrace(e); } } diff --git a/backend/src/main/java/com/bakdata/conquery/resources/admin/rest/AdminResource.java b/backend/src/main/java/com/bakdata/conquery/resources/admin/rest/AdminResource.java index dec6699bb0..479e0e8751 100644 --- a/backend/src/main/java/com/bakdata/conquery/resources/admin/rest/AdminResource.java +++ b/backend/src/main/java/com/bakdata/conquery/resources/admin/rest/AdminResource.java @@ -3,6 +3,7 @@ import static com.bakdata.conquery.resources.ResourceConstants.JOB_ID; import java.time.LocalDate; +import java.util.Collection; import java.util.Objects; import java.util.Optional; import java.util.OptionalLong; @@ -35,7 +36,6 @@ import com.bakdata.conquery.models.worker.DatasetRegistry; import com.bakdata.conquery.models.worker.ShardNodeInformation; import com.bakdata.conquery.resources.admin.ui.AdminUIResource; -import com.google.common.collect.ImmutableMap; import io.dropwizard.auth.Auth; import lombok.RequiredArgsConstructor; @@ -89,7 +89,7 @@ public Response cancelJob(@PathParam(JOB_ID) UUID jobId) { @GET @Path("/jobs/") - public ImmutableMap getJobs() { + public Collection getJobs() { return processor.getJobs(); } diff --git a/backend/src/main/java/com/bakdata/conquery/resources/api/ConceptsProcessor.java b/backend/src/main/java/com/bakdata/conquery/resources/api/ConceptsProcessor.java index c59be20ec6..a3ff393d83 100644 --- a/backend/src/main/java/com/bakdata/conquery/resources/api/ConceptsProcessor.java +++ b/backend/src/main/java/com/bakdata/conquery/resources/api/ConceptsProcessor.java @@ -139,7 +139,6 @@ public FrontendPreviewConfig getEntityPreviewFrontendConfig(Dataset dataset) { // Connectors only act as bridge to table for the fronted, but also provide ConceptColumnT semantic return new FrontendPreviewConfig( - previewConfig.getObservationStart(), previewConfig.getAllConnectors() .stream() .map(id -> new FrontendPreviewConfig.Labelled(id.toString(), namespace.getCentralRegistry().resolve(id).getTable().getLabel())) diff --git a/backend/src/main/java/com/bakdata/conquery/resources/api/ConfigResource.java b/backend/src/main/java/com/bakdata/conquery/resources/api/ConfigResource.java index 8f1f695329..deeb7e5d21 100644 --- a/backend/src/main/java/com/bakdata/conquery/resources/api/ConfigResource.java +++ b/backend/src/main/java/com/bakdata/conquery/resources/api/ConfigResource.java @@ -1,5 +1,7 @@ package com.bakdata.conquery.resources.api; +import java.time.Year; + import javax.inject.Inject; import javax.ws.rs.GET; import javax.ws.rs.Path; @@ -37,7 +39,8 @@ public FrontendConfiguration getFrontendConfig() { frontendConfig.getCurrency(), idColumns, frontendConfig.getManualUrl(), - frontendConfig.getContactEmail() + frontendConfig.getContactEmail(), + Year.now().minusYears(frontendConfig.getObservationPeriodYears()).atDay(1) ); } diff --git a/backend/src/main/java/com/bakdata/conquery/tasks/ReloadMetaStorageTask.java b/backend/src/main/java/com/bakdata/conquery/tasks/ReloadMetaStorageTask.java new file mode 100644 index 0000000000..fb5d423dc2 --- /dev/null +++ b/backend/src/main/java/com/bakdata/conquery/tasks/ReloadMetaStorageTask.java @@ -0,0 +1,56 @@ +package com.bakdata.conquery.tasks; + +import java.io.PrintWriter; +import java.util.List; +import java.util.Map; + +import com.bakdata.conquery.io.storage.MetaStorage; +import com.google.common.base.Stopwatch; +import io.dropwizard.servlets.tasks.Task; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class ReloadMetaStorageTask extends Task { + + private final MetaStorage storage; + + public ReloadMetaStorageTask(MetaStorage storage) { + super("reload-meta-storage"); + this.storage = storage; + } + + @Override + public void execute(Map> parameters, PrintWriter output) throws Exception { + final Stopwatch timer = Stopwatch.createStarted(); + + output.println("BEGIN reloading MetaStorage."); + + { + final int allUsers = storage.getAllUsers().size(); + final int allExecutions = storage.getAllExecutions().size(); + final int allFormConfigs = storage.getAllFormConfigs().size(); + final int allGroups = storage.getAllGroups().size(); + final int allRoles = storage.getAllRoles().size(); + + log.debug("BEFORE: Have {} Users, {} Groups, {} Roles, {} Executions, {} FormConfigs.", + allUsers, allGroups, allRoles, allExecutions, allFormConfigs); + } + + storage.loadData(); + output.println("DONE reloading MetaStorage within %s.".formatted(timer.elapsed())); + + { + final int allUsers = storage.getAllUsers().size(); + final int allExecutions = storage.getAllExecutions().size(); + final int allFormConfigs = storage.getAllFormConfigs().size(); + final int allGroups = storage.getAllGroups().size(); + final int allRoles = storage.getAllRoles().size(); + + log.debug("AFTER: Have {} Users, {} Groups, {} Roles, {} Executions, {} FormConfigs.", + allUsers, allGroups, allRoles, allExecutions, allFormConfigs); + } + + + + } +} diff --git a/backend/src/main/resources/com/bakdata/conquery/frontend/forms/export_form.frontend_conf.json b/backend/src/main/resources/com/bakdata/conquery/frontend/forms/export_form.frontend_conf.json index 8516063810..cf71a70858 100644 --- a/backend/src/main/resources/com/bakdata/conquery/frontend/forms/export_form.frontend_conf.json +++ b/backend/src/main/resources/com/bakdata/conquery/frontend/forms/export_form.frontend_conf.json @@ -27,8 +27,8 @@ "en": "Cohort (Previous Query)" }, "dropzoneLabel": { - "de": "Füge eine Versichertengruppe aus einer bestehenden Anfrage hinzu", - "en": "Add a cohort from a previous query" + "de": "Füge eine Versichertengruppe aus einer bestehenden Anfrage hinzu. Ist das Feld leer wird die Gesamtpopulation verwendet.", + "en": "Add a cohort from a previous query. When no population is provided, the entire dataset's population is used." }, "validations": [ ], diff --git a/backend/src/main/resources/com/bakdata/conquery/frontend/forms/table_export_form.frontend_conf.json b/backend/src/main/resources/com/bakdata/conquery/frontend/forms/table_export_form.frontend_conf.json index e736367a43..cb51887547 100644 --- a/backend/src/main/resources/com/bakdata/conquery/frontend/forms/table_export_form.frontend_conf.json +++ b/backend/src/main/resources/com/bakdata/conquery/frontend/forms/table_export_form.frontend_conf.json @@ -27,8 +27,8 @@ "en": "Cohort (Previous Query)" }, "dropzoneLabel": { - "de": "Füge eine Versichertengruppe aus einer bestehenden Anfrage hinzu.", - "en": "Add a cohort from a previous query" + "de": "Füge eine Versichertengruppe aus einer bestehenden Anfrage hinzu. Ist das Feld leer wird die Gesamtpopulation verwendet.", + "en": "Add a cohort from a previous query. When no population is provided, the entire dataset's population is used." }, "validations": [ ], diff --git a/backend/src/main/resources/com/bakdata/conquery/resources/admin/ui/jobs.html.ftl b/backend/src/main/resources/com/bakdata/conquery/resources/admin/ui/jobs.html.ftl index 26e0847442..a29f0563d0 100644 --- a/backend/src/main/resources/com/bakdata/conquery/resources/admin/ui/jobs.html.ftl +++ b/backend/src/main/resources/com/bakdata/conquery/resources/admin/ui/jobs.html.ftl @@ -1,12 +1,41 @@ <#import "templates/template.html.ftl" as layout> <@layout.layout> - <#list c as node, status> +
+
+ +
+ +
+
+ + + <#list c as status>
- ${node} + ${status.origin} ${(status.dataset)!} updated ${status.ageString} ago ${status.jobs?size} @@ -21,7 +50,7 @@
-
+
@@ -40,32 +69,4 @@
- -
-
- -
- -
-
\ No newline at end of file diff --git a/backend/src/test/java/com/bakdata/conquery/integration/tests/EntityExportTest.java b/backend/src/test/java/com/bakdata/conquery/integration/tests/EntityExportTest.java index ab862e3de4..8969932dc7 100644 --- a/backend/src/test/java/com/bakdata/conquery/integration/tests/EntityExportTest.java +++ b/backend/src/test/java/com/bakdata/conquery/integration/tests/EntityExportTest.java @@ -13,7 +13,6 @@ import java.util.Set; import java.util.stream.Collectors; -import javax.servlet.http.HttpServletRequest; import javax.ws.rs.client.Entity; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; @@ -24,7 +23,6 @@ import com.bakdata.conquery.integration.common.RequiredData; import com.bakdata.conquery.integration.json.JsonIntegrationTest; import com.bakdata.conquery.integration.json.QueryTest; -import com.bakdata.conquery.models.auth.entities.Subject; import com.bakdata.conquery.models.common.Range; import com.bakdata.conquery.models.datasets.Dataset; import com.bakdata.conquery.models.datasets.PreviewConfig; @@ -42,7 +40,6 @@ import com.bakdata.conquery.resources.admin.rest.AdminDatasetResource; import com.bakdata.conquery.resources.api.DatasetQueryResource; import com.bakdata.conquery.resources.api.EntityPreviewRequest; -import com.bakdata.conquery.resources.api.QueryResource; import com.bakdata.conquery.resources.hierarchies.HierarchyHelper; import com.bakdata.conquery.util.support.StandaloneSupport; import com.bakdata.conquery.util.support.TestConquery; @@ -50,9 +47,6 @@ import lombok.extern.slf4j.Slf4j; import org.assertj.core.description.LazyTextDescription; -/** - * Adapted from {@link com.bakdata.conquery.integration.tests.deletion.ImportDeletionTest}, tests {@link QueryResource#getEntityData(Subject, QueryResource.EntityPreview, HttpServletRequest)}. - */ @Slf4j public class EntityExportTest implements ProgrammaticIntegrationTest { @@ -100,8 +94,6 @@ public void execute(String name, TestConquery testConquery) throws Exception { final PreviewConfig previewConfig = new PreviewConfig(); - previewConfig.setObservationStart(LocalDate.of(2010,1,1)); - previewConfig.setInfoCardSelects(List.of( new PreviewConfig.InfoCardSelect("Age", SelectId.Parser.INSTANCE.parsePrefixed(dataset.getName(), "tree1.connector.age"), null), new PreviewConfig.InfoCardSelect("Values", valuesSelectId, null) @@ -245,9 +237,9 @@ public void execute(String name, TestConquery testConquery) throws Exception { assertThat(resultLines.readEntity(String.class).lines().collect(Collectors.toList())) .containsExactlyInAnyOrder( "result,dates,source,secondaryid,table1 column,table2 column", - "1,{2013-11-10/2013-11-10},table1,External: oneone,tree1.child_a,", - "1,{2012-01-01/2012-01-01},table2,2222,,tree2", - "1,{2010-07-15/2010-07-15},table2,External: threethree,,tree2" + "1,{2013-11-10/2013-11-10},table1,External: oneone,EntityExportTest.tree1.child_a,", + "1,{2012-01-01/2012-01-01},table2,2222,,EntityExportTest.tree2", + "1,{2010-07-15/2010-07-15},table2,External: threethree,,EntityExportTest.tree2" ); } diff --git a/backend/src/test/resources/tests/aggregator/CONCEPT_COLUMN_SELECTS/CONCEPT_VALUES_RESOLVED.test.json b/backend/src/test/resources/tests/aggregator/CONCEPT_COLUMN_SELECTS/CONCEPT_VALUES_RESOLVED.test.json index fc85a046d6..a98482ec1c 100644 --- a/backend/src/test/resources/tests/aggregator/CONCEPT_COLUMN_SELECTS/CONCEPT_VALUES_RESOLVED.test.json +++ b/backend/src/test/resources/tests/aggregator/CONCEPT_COLUMN_SELECTS/CONCEPT_VALUES_RESOLVED.test.json @@ -6,7 +6,7 @@ "type": "CONCEPT_QUERY", "root": { "type": "CONCEPT", - "selects" : [ + "selects": [ "tree.select" ], "ids": [ @@ -28,9 +28,9 @@ "type": "TREE", "selects": [ { - "type" : "CONCEPT_VALUES", - "name" : "select", - "asIds" : true + "type": "CONCEPT_VALUES", + "name": "select", + "asIds": true } ], "connectors": [ diff --git a/backend/src/test/resources/tests/aggregator/CONCEPT_COLUMN_SELECTS/expected_resolved.csv b/backend/src/test/resources/tests/aggregator/CONCEPT_COLUMN_SELECTS/expected_resolved.csv index 36a1e899b1..d99355deeb 100644 --- a/backend/src/test/resources/tests/aggregator/CONCEPT_COLUMN_SELECTS/expected_resolved.csv +++ b/backend/src/test/resources/tests/aggregator/CONCEPT_COLUMN_SELECTS/expected_resolved.csv @@ -1,5 +1,5 @@ result,dates,tree select -1,{2012-01-01/2012-01-02},"{tree.test_child2,tree.test_child1}" -2,{2010-07-15/2010-07-15},{tree.test_child2} -3,{2013-11-10/2013-11-10},{tree.test_child1} -4,{2012-11-11/2012-11-11},{tree.test_child2} \ No newline at end of file +1,{2012-01-01/2012-01-02},"{CONCEPT_VALUES$20$28Resolved$29$20Test.tree.test_child2,CONCEPT_VALUES$20$28Resolved$29$20Test.tree.test_child1}" +2,{2010-07-15/2010-07-15},{CONCEPT_VALUES$20$28Resolved$29$20Test.tree.test_child2} +3,{2013-11-10/2013-11-10},{CONCEPT_VALUES$20$28Resolved$29$20Test.tree.test_child1} +4,{2012-11-11/2012-11-11},{CONCEPT_VALUES$20$28Resolved$29$20Test.tree.test_child2} \ No newline at end of file diff --git a/backend/src/test/resources/tests/query/TABLE_EXPORT/RAW_TABLE_EXPORT.test.json b/backend/src/test/resources/tests/query/TABLE_EXPORT/RAW_TABLE_EXPORT.test.json index c3c321cdc0..bf49129fca 100644 --- a/backend/src/test/resources/tests/query/TABLE_EXPORT/RAW_TABLE_EXPORT.test.json +++ b/backend/src/test/resources/tests/query/TABLE_EXPORT/RAW_TABLE_EXPORT.test.json @@ -1,6 +1,6 @@ { "type": "QUERY_TEST", - "label": "TABLE_EXPORT Test", + "label": "RAW_TABLE_EXPORT Test", "expectedCsv": "tests/query/TABLE_EXPORT/raw_expected.csv", "query": { "type": "TABLE_EXPORT", diff --git a/backend/src/test/resources/tests/query/TABLE_EXPORT/TABLE_EXPORT.test.json b/backend/src/test/resources/tests/query/TABLE_EXPORT/TABLE_EXPORT.test.json index 7107f3904a..4b78d2cf0b 100644 --- a/backend/src/test/resources/tests/query/TABLE_EXPORT/TABLE_EXPORT.test.json +++ b/backend/src/test/resources/tests/query/TABLE_EXPORT/TABLE_EXPORT.test.json @@ -34,7 +34,7 @@ "min": "2000-01-01", "max": "2020-12-31" }, - "rawConceptValues" : false, + "rawConceptValues": false, "query": { "type": "CONCEPT_QUERY", "root": { diff --git a/backend/src/test/resources/tests/query/TABLE_EXPORT/expected.csv b/backend/src/test/resources/tests/query/TABLE_EXPORT/expected.csv index 9dab1a4faa..88e9196f5d 100644 --- a/backend/src/test/resources/tests/query/TABLE_EXPORT/expected.csv +++ b/backend/src/test/resources/tests/query/TABLE_EXPORT/expected.csv @@ -1,8 +1,8 @@ result,dates,source,SecondaryId,table1 value,table1 code,table2 value,table2 code -a,{2020-06-30/2020-06-30},table2,,,,13.0,concept.a_child -a,{2014-06-30/2015-06-30},table1,f_a1,1.0,concept.a_child,, -a,{2016-06-30/2016-06-30},table1,f_a1,1.0,concept.a_child,, -a,{2014-06-30/2015-06-30},table1,f_a2,1.0,concept.a_child,, -a,{2010-06-30/2010-06-30},table1,,1.0,concept.a_child,, -b,{2015-02-03/2015-06-30},table1,f_b1,1.0,concept,, -a,{2020-06-30/2020-06-30},table2,,,,13.0,concept \ No newline at end of file +a,{2020-06-30/2020-06-30},table2,,,,13.0,TABLE_EXPORT$20Test.concept.a_child +a,{2014-06-30/2015-06-30},table1,f_a1,1.0,TABLE_EXPORT$20Test.concept.a_child,, +a,{2016-06-30/2016-06-30},table1,f_a1,1.0,TABLE_EXPORT$20Test.concept.a_child,, +a,{2014-06-30/2015-06-30},table1,f_a2,1.0,TABLE_EXPORT$20Test.concept.a_child,, +a,{2010-06-30/2010-06-30},table1,,1.0,TABLE_EXPORT$20Test.concept.a_child,, +b,{2015-02-03/2015-06-30},table1,f_b1,1.0,TABLE_EXPORT$20Test.concept,, +a,{2020-06-30/2020-06-30},table2,,,,13.0,TABLE_EXPORT$20Test.concept \ No newline at end of file diff --git a/frontend/.env.e2e b/frontend/.env.e2e index 1e786c501c..1e5b246a4f 100644 --- a/frontend/.env.e2e +++ b/frontend/.env.e2e @@ -17,4 +17,4 @@ REACT_APP_LANG=de REACT_APP_IDP_ENABLE=false REACT_APP_IDP_URL=http://localhost:8080/auth REACT_APP_IDP_REALM=Myrealm -REACT_APP_IDP_CLIENT_ID=frontend +REACT_APP_IDP_CLIENT_ID=frontend \ No newline at end of file diff --git a/frontend/.env.example b/frontend/.env.example index 40734bdcc7..f3763b5599 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -22,4 +22,4 @@ REACT_APP_IDP_URL=http://localhost:8080/auth # NOTE: You will need to create this realm in keycloak REACT_APP_IDP_REALM=Myrealm # NOTE: You will need to create this client in keycloak -REACT_APP_IDP_CLIENT_ID=frontend \ No newline at end of file +REACT_APP_IDP_CLIENT_ID=frontend diff --git a/frontend/package.json b/frontend/package.json index 5afc43037c..d886016692 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -32,6 +32,7 @@ "@fortawesome/free-regular-svg-icons": "^6.3.0", "@fortawesome/free-solid-svg-icons": "^6.3.0", "@fortawesome/react-fontawesome": "^0.2.0", + "@paralleldrive/cuid2": "^2.2.0", "@react-keycloak-fork/web": "^4.0.3", "@tippyjs/react": "^4.2.6", "@types/file-saver": "^2.0.5", diff --git a/frontend/src/app-theme.ts b/frontend/src/app-theme.ts index 7804351455..4435b90328 100644 --- a/frontend/src/app-theme.ts +++ b/frontend/src/app-theme.ts @@ -7,31 +7,37 @@ import spinner from "./images/spinner.png"; export const theme: Theme = { col: { bg: "#fafafa", - bgAlt: "#f4f6f5", black: "#222", gray: "#888", grayMediumLight: "#aaa", grayLight: "#dadada", grayVeryLight: "#eee", - blueGrayDark: "#0C6427", - blueGray: "#72757C", - blueGrayLight: "#52A55C", - blueGrayVeryLight: "#A4E6AC", red: "#b22125", green: "#36971C", orange: "#E9711C", palette: [ - "#f9c74f", - "#f8961e", "#277da1", - "#90be6d", "#43aa8b", - "#f94144", "#5e60ce", + "#f9c74f", + "#90be6d", + "#f8961e", + "#f94144", "#aaa", "#777", "#fff", ], + fileTypes: { + csv: "#007BFF", + pdf: "#d73a49", + zip: "#6f42c1", + xlsx: "#28a745", + }, + bgAlt: "#f4f6f5", + blueGrayDark: "#1f5f30", + blueGray: "#98b099", + blueGrayLight: "#ccd6d0", + blueGrayVeryLight: "#dadedb", }, img: { logo: logo, diff --git a/frontend/src/js/api/api.ts b/frontend/src/js/api/api.ts index b651aedcac..5b0a7f5836 100644 --- a/frontend/src/js/api/api.ts +++ b/frontend/src/js/api/api.ts @@ -1,5 +1,6 @@ import { useCallback } from "react"; +import { EditorV2Query } from "../editor-v2/types"; import { EntityId } from "../entity-history/reducer"; import { apiUrl } from "../environment"; import type { FormConfigT } from "../previous-queries/list/reducer"; @@ -95,7 +96,10 @@ export const usePostQueries = () => { return useCallback( ( datasetId: DatasetT["id"], - query: StandardQueryStateT | ValidatedTimebasedQueryStateT, + query: + | StandardQueryStateT + | ValidatedTimebasedQueryStateT + | EditorV2Query, options: { queryType: string; selectedSecondaryId?: string | null }, ) => api({ diff --git a/frontend/src/js/api/apiHelper.ts b/frontend/src/js/api/apiHelper.ts index 852e4185b3..a20af07de0 100644 --- a/frontend/src/js/api/apiHelper.ts +++ b/frontend/src/js/api/apiHelper.ts @@ -6,6 +6,7 @@ // Some keys are added (e.g. the query type attribute) import { isEmpty } from "../common/helpers/commonHelper"; import { exists } from "../common/helpers/exists"; +import { EditorV2Query, Tree } from "../editor-v2/types"; import { nodeIsConceptQueryNode } from "../model/node"; import { isLabelPristine } from "../standard-query-editor/helper"; import type { StandardQueryStateT } from "../standard-query-editor/queryReducer"; @@ -114,6 +115,11 @@ const createConceptQuery = (root: T) => ({ root, }); +const createOr = (children: T) => ({ + type: "OR" as const, + children, +}); + const createAnd = (children: T) => ({ type: "AND" as const, children, @@ -204,11 +210,98 @@ const transformTimebasedQueryToApi = (query: ValidatedTimebasedQueryStateT) => ), ); +const transformTreeToApi = (tree: Tree): unknown => { + let dateRestriction; + if (tree.dates?.restriction) { + dateRestriction = createDateRestriction(tree.dates.restriction, null); + } + + let negation; + if (tree.negation) { + negation = createNegation(null); + } + + let node; + if (!tree.children) { + if (!tree.data) { + throw new Error( + "Tree has no children and no data, this shouldn't happen.", + ); + } + + node = createQueryConcept(tree.data); + } else { + switch (tree.children.connection) { + case "and": + node = createAnd(tree.children.items.map(transformTreeToApi)); + break; + case "or": + node = createOr(tree.children.items.map(transformTreeToApi)); + break; + case "time": + node = { + type: "BEFORE", // SHOULD BE: tree.children.operator, + days: { + min: tree.children.interval + ? tree.children.interval.min === null + ? 1 + : tree.children.interval.max + : undefined, + max: tree.children.interval + ? tree.children.interval.max === null + ? undefined + : tree.children.interval.max + : undefined, + }, + // TODO: improve this to be more flexible with the "preceding" and "index" keys + // based on the operator, which would be "before" | "after" | "while" + preceding: { + sampler: "EARLIEST", // SHOULD BE: tree.children.timestamps[0], + child: transformTreeToApi(tree.children.items[0]), + }, + index: { + sampler: "EARLIEST", // SHOULD BE: tree.children.timestamps[1] + child: transformTreeToApi(tree.children.items[1]), + }, + }; + break; + } + } + + if (dateRestriction && negation) { + return { + ...dateRestriction, + child: { + ...negation, + child: node, + }, + }; + } else if (dateRestriction) { + return { + ...dateRestriction, + child: node, + }; + } else if (negation) { + return { + ...negation, + child: node, + }; + } else { + return node; + } +}; + +const transformEditorV2QueryToApi = (query: EditorV2Query) => { + if (!query.tree) return null; + + return createConceptQuery(transformTreeToApi(query.tree)); +}; + // The query state already contains the query. // But small additions are made (properties allowlisted), empty things filtered out // to make it compatible with the backend API export const transformQueryToApi = ( - query: StandardQueryStateT | ValidatedTimebasedQueryStateT, + query: StandardQueryStateT | ValidatedTimebasedQueryStateT | EditorV2Query, options: { queryType: string; selectedSecondaryId?: string | null }, ) => { switch (options.queryType) { @@ -221,6 +314,8 @@ export const transformQueryToApi = ( query as StandardQueryStateT, options.selectedSecondaryId, ); + case "editorV2": + return transformEditorV2QueryToApi(query as EditorV2Query); default: return null; } diff --git a/frontend/src/js/api/types.ts b/frontend/src/js/api/types.ts index 55d19c2008..2820fcc75e 100644 --- a/frontend/src/js/api/types.ts +++ b/frontend/src/js/api/types.ts @@ -163,6 +163,7 @@ export interface ConceptBaseT { description?: string; // Empty array: key not defined additionalInfos?: InfoT[]; // Empty array: key not defined dateRange?: DateRangeT; + excludeFromTimeAggregation?: boolean; // To default-exclude some concepts from time aggregation } export type ConceptStructT = ConceptBaseT; @@ -296,6 +297,7 @@ export interface GetFrontendConfigResponseT { queryUpload: QueryUploadConfigT; manualUrl?: string; contactEmail?: string; + observationPeriodStart?: string; // yyyy-mm-dd format, start of the data } export type GetConceptResponseT = Record; @@ -326,7 +328,8 @@ export type ColumnDescriptionKind = | "MONEY" | "DATE" | "DATE_RANGE" - | "LIST[DATE_RANGE]"; + | "LIST[DATE_RANGE]" + | "LIST[STRING]"; export interface ColumnDescriptionSemanticColumn { type: "COLUMN"; @@ -384,6 +387,7 @@ export interface ColumnDescription { // `label` matches column name in CSV // So it's more of an id, TODO: rename this to 'id', label: string; + description: string | null; type: ColumnDescriptionKind; semantics: ColumnDescriptionSemantic[]; @@ -533,7 +537,6 @@ export interface HistorySources { export type GetEntityHistoryDefaultParamsResponse = HistorySources & { searchConcept: string | null; // concept id searchFilters?: string[]; // allowlisted filter ids within the searchConcept - observationPeriodMin: string; // yyyy-MM-dd }; export interface EntityInfo { @@ -564,13 +567,9 @@ export interface TimeStratifiedInfo { totals: { [label: string]: number | string[]; }; - columns: { - label: string; // Matches `label` with `year.values` and `year.quarters[].values` - defaultLabel: string; // Probably not used by us - description: string | null; - type: ColumnDescriptionKind; // Relevant to show e.g. € for money - semantics: ColumnDescriptionSemantic[]; // Probably not used by us - }[]; + // `columns[].label` matches with + // `year.values` and `year.quarters[].values` + columns: ColumnDescription[]; years: TimeStratifiedInfoYear[]; } diff --git a/frontend/src/js/app/RightPane.tsx b/frontend/src/js/app/RightPane.tsx index 801662bf27..d858d3918c 100644 --- a/frontend/src/js/app/RightPane.tsx +++ b/frontend/src/js/app/RightPane.tsx @@ -1,13 +1,17 @@ import styled from "@emotion/styled"; -import { useMemo } from "react"; +import { useCallback, useMemo, useState } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; import { useTranslation } from "react-i18next"; import { useSelector } from "react-redux"; +import { EditorV2 } from "../editor-v2/EditorV2"; +import { ResetableErrorBoundary } from "../error-fallback/ResetableErrorBoundary"; import FormsTab from "../external-forms/FormsTab"; import Pane from "../pane/Pane"; import { TabNavigationTab } from "../pane/TabNavigation"; import StandardQueryEditorTab from "../standard-query-editor/StandardQueryEditorTab"; import TimebasedQueryEditorTab from "../timebased-query-editor/TimebasedQueryEditorTab"; +import { getUserSettings, storeUserSettings } from "../user/userSettings"; import type { StateT } from "./reducers"; @@ -23,12 +27,31 @@ const SxPane = styled(Pane)` background-color: ${({ theme }) => theme.col.bgAlt}; `; +const useEditorV2 = () => { + const [showEditorV2, setShowEditorV2] = useState( + getUserSettings().showEditorV2, + ); + + const toggleEditorV2 = useCallback(() => { + setShowEditorV2(!showEditorV2); + storeUserSettings({ showEditorV2: !showEditorV2 }); + }, [showEditorV2]); + + useHotkeys("shift+alt+e", toggleEditorV2, [showEditorV2]); + + return { + showEditorV2, + }; +}; + const RightPane = () => { const { t } = useTranslation(); const activeTab = useSelector( (state) => state.panes.right.activeTab, ); + const { showEditorV2 } = useEditorV2(); + const tabs: TabNavigationTab[] = useMemo( () => [ { @@ -36,18 +59,24 @@ const RightPane = () => { label: t("rightPane.queryEditor"), tooltip: t("help.tabQueryEditor"), }, - { - key: "timebasedQueryEditor", - label: t("rightPane.timebasedQueryEditor"), - tooltip: t("help.tabTimebasedEditor"), - }, + showEditorV2 + ? { + key: "editorV2", + label: t("rightPane.editorV2"), + tooltip: t("help.tabEditorV2"), + } + : { + key: "timebasedQueryEditor", + label: t("rightPane.timebasedQueryEditor"), + tooltip: t("help.tabTimebasedEditor"), + }, { key: "externalForms", label: t("rightPane.externalForms"), tooltip: t("help.tabFormEditor"), }, ], - [t], + [t, showEditorV2], ); return ( @@ -56,10 +85,24 @@ const RightPane = () => { - + {showEditorV2 ? ( + + ) : ( + + )} - + + + ); diff --git a/frontend/src/js/app/reducers.ts b/frontend/src/js/app/reducers.ts index daf3f1c56e..af19ae2b33 100644 --- a/frontend/src/js/app/reducers.ts +++ b/frontend/src/js/app/reducers.ts @@ -64,6 +64,7 @@ export type StateT = { previousQueriesFolderFilter: PreviousQueriesFolderFilterStateT; preview: PreviewStateT; snackMessage: SnackMessageStateT; + editorV2QueryRunner: QueryRunnerStateT; queryEditor: { query: StandardQueryStateT; selectedSecondaryId: SelectedSecondaryIdStateT; @@ -101,6 +102,7 @@ const buildAppReducer = () => { preview, user, entityHistory, + editorV2QueryRunner: createQueryRunnerReducer("editorV2"), queryEditor: combineReducers({ query: queryReducer, selectedSecondaryId: selectedSecondaryIdsReducer, diff --git a/frontend/src/js/button/DownloadButton.tsx b/frontend/src/js/button/DownloadButton.tsx index 6fb2d32473..fb1a2d6872 100644 --- a/frontend/src/js/button/DownloadButton.tsx +++ b/frontend/src/js/button/DownloadButton.tsx @@ -1,3 +1,4 @@ +import { useTheme } from "@emotion/react"; import styled from "@emotion/styled"; import { IconProp } from "@fortawesome/fontawesome-svg-core"; import { @@ -8,7 +9,7 @@ import { faFileExcel, faFilePdf, } from "@fortawesome/free-solid-svg-icons"; -import { ReactNode, useContext, forwardRef } from "react"; +import { ReactNode, useContext, forwardRef, useMemo } from "react"; import { ResultUrlWithLabel } from "../api/types"; import { AuthTokenContext } from "../authorization/AuthTokenProvider"; @@ -24,21 +25,33 @@ const Link = styled("a")` line-height: 1; `; -const fileTypeToIcon: Record = { - ZIP: faFileArchive, - XLSX: faFileExcel, - PDF: faFilePdf, - CSV: faFileCsv, -}; -function getFileIcon(url: string): IconProp { - // Forms +interface FileIcon { + icon: IconProp; + color?: string; +} + +function useFileIcon(url: string): FileIcon { + const theme = useTheme(); + + const fileTypeToFileIcon: Record = useMemo( + () => ({ + ZIP: { icon: faFileArchive, color: theme.col.fileTypes.zip }, + XLSX: { icon: faFileExcel, color: theme.col.fileTypes.xlsx }, + PDF: { icon: faFilePdf, color: theme.col.fileTypes.pdf }, + CSV: { icon: faFileCsv, color: theme.col.fileTypes.csv }, + }), + [theme], + ); + if (url.includes(".")) { const ext = getEnding(url); - if (ext in fileTypeToIcon) { - return fileTypeToIcon[ext]; + + if (ext in fileTypeToFileIcon) { + return fileTypeToFileIcon[ext]; } } - return faFileDownload; + + return { icon: faFileDownload }; } interface Props extends Omit { @@ -47,11 +60,20 @@ interface Props extends Omit { children?: ReactNode; simpleIcon?: boolean; onClick?: () => void; + showColoredIcon?: boolean; } const DownloadButton = forwardRef( ( - { simpleIcon, resultUrl, className, children, onClick, ...restProps }, + { + simpleIcon, + resultUrl, + className, + children, + onClick, + showColoredIcon, + ...restProps + }, ref, ) => { const { authToken } = useContext(AuthTokenContext); @@ -60,12 +82,16 @@ const DownloadButton = forwardRef( authToken, )}&charset=ISO_8859_1`; + const { icon, color } = useFileIcon(resultUrl.url); + return ( {children} diff --git a/frontend/src/js/button/IconButton.tsx b/frontend/src/js/button/IconButton.tsx index 0ee51dde73..772e52b4a2 100644 --- a/frontend/src/js/button/IconButton.tsx +++ b/frontend/src/js/button/IconButton.tsx @@ -11,11 +11,14 @@ interface StyledFaIconProps extends FaIconPropsT { red?: boolean; secondary?: boolean; hasChildren: boolean; + iconColor?: string; } const SxFaIcon = styled(FaIcon)` - color: ${({ theme, active, red, secondary, light }) => - red + color: ${({ theme, active, red, secondary, light, iconColor }) => + iconColor + ? iconColor + : red ? theme.col.red : active ? theme.col.blueGrayDark @@ -42,15 +45,16 @@ const SxBasicButton = styled(BasicButton)<{ tight?: boolean; bgHover?: boolean; red?: boolean; + large?: boolean; }>` background-color: transparent; color: ${({ theme, active, secondary, red }) => - active + red + ? theme.col.red + : active ? theme.col.blueGrayDark : secondary ? theme.col.orange - : red - ? theme.col.red : theme.col.black}; opacity: ${({ frame }) => (frame ? 1 : 0.75)}; transition: opacity ${({ theme }) => theme.transitionTime}, @@ -62,7 +66,7 @@ const SxBasicButton = styled(BasicButton)<{ display: inline-flex; align-items: center; gap: ${({ tight }) => (tight ? "5px" : "10px")}; - + font-size: ${({ theme, large }) => (large ? theme.font.md : theme.font.sm)}; &:hover { opacity: 1; @@ -77,6 +81,12 @@ const SxBasicButton = styled(BasicButton)<{ } `; +const Children = styled("span")` + display: flex; + align-items: center; + gap: 5px; +`; + export interface IconButtonPropsT extends BasicButtonProps { iconProps?: IconStyleProps; active?: boolean; @@ -92,6 +102,7 @@ export interface IconButtonPropsT extends BasicButtonProps { light?: boolean; fixedIconWidth?: number; bgHover?: boolean; + iconColor?: string; } // A button that is prefixed by an icon @@ -111,6 +122,7 @@ const IconButton = forwardRef( light, fixedIconWidth, bgHover, + iconColor, ...restProps }, ref, @@ -129,6 +141,7 @@ const IconButton = forwardRef( tight={tight} small={small} light={light} + iconColor={iconColor} {...iconProps} /> ); @@ -153,6 +166,7 @@ const IconButton = forwardRef( secondary, light, fixedIconWidth, + iconColor, ]); return ( ( tight={tight} bgHover={bgHover} red={red} + large={large} {...restProps} ref={ref} > {iconElement} - {children && {children}} + {children && {children}} ); }, diff --git a/frontend/src/js/common/components/KeyboardKey.tsx b/frontend/src/js/common/components/KeyboardKey.tsx new file mode 100644 index 0000000000..ba19a4788a --- /dev/null +++ b/frontend/src/js/common/components/KeyboardKey.tsx @@ -0,0 +1,16 @@ +import styled from "@emotion/styled"; +import { ReactNode } from "react"; + +const KeyShape = styled("kbd")` + padding: 2px 4px; + border: 1px solid ${({ theme }) => theme.col.grayLight}; + box-shadow: 0 0 3px 0 ${({ theme }) => theme.col.grayLight}; + font-size: ${({ theme }) => theme.font.xs}; + line-height: 1; + border-radius: ${({ theme }) => theme.borderRadius}; + text-transform: uppercase; +`; + +export const KeyboardKey = ({ children }: { children: ReactNode }) => ( + {children} +); diff --git a/frontend/src/js/common/helpers/dateHelper.ts b/frontend/src/js/common/helpers/dateHelper.ts index c5fc13fc8c..f7d7a566ad 100644 --- a/frontend/src/js/common/helpers/dateHelper.ts +++ b/frontend/src/js/common/helpers/dateHelper.ts @@ -216,11 +216,13 @@ export function getFirstAndLastDateOfRange(dateRangeStr: string): { export function useMonthName(date: Date): string { const locale = useDateLocale(); + return format(date, "LLLL", { locale }); } export function useMonthNames(): string[] { const locale = useDateLocale(); + return [...Array(12).keys()].map((month) => { const date = new Date(); date.setMonth(month); diff --git a/frontend/src/js/concept-trees/ConceptTreeNode.tsx b/frontend/src/js/concept-trees/ConceptTreeNode.tsx index ffe51d382a..5095f42d27 100644 --- a/frontend/src/js/concept-trees/ConceptTreeNode.tsx +++ b/frontend/src/js/concept-trees/ConceptTreeNode.tsx @@ -1,13 +1,6 @@ import styled from "@emotion/styled"; -import { FC } from "react"; - -import type { - ConceptIdT, - InfoT, - DateRangeT, - ConceptT, - ConceptElementT, -} from "../api/types"; + +import type { ConceptIdT, ConceptT, ConceptElementT } from "../api/types"; import { useOpenableConcept } from "../concept-trees-open/useOpenableConcept"; import { resetSelects } from "../model/select"; import { resetTables } from "../model/table"; @@ -22,44 +15,18 @@ const Root = styled("div")` font-size: ${({ theme }) => theme.font.sm}; `; -// Concept data that is necessary to display tree nodes. Includes additional infos -// for the tooltip as well as the id of the corresponding tree -interface TreeNodeData { - label: string; - description?: string; - active?: boolean; - matchingEntries: number | null; - matchingEntities: number | null; - dateRange?: DateRangeT; - additionalInfos?: InfoT[]; - children?: ConceptIdT[]; -} - -interface PropsT { - rootConceptId: ConceptIdT; - conceptId: ConceptIdT; - data: TreeNodeData; - depth: number; - search: SearchT; -} - -const selectTreeNodeData = (concept: ConceptT) => ({ - label: concept.label, - description: concept.description, - active: concept.active, - matchingEntries: concept.matchingEntries, - matchingEntities: concept.matchingEntities, - dateRange: concept.dateRange, - additionalInfos: concept.additionalInfos, - children: concept.children, -}); - -const ConceptTreeNode: FC = ({ +const ConceptTreeNode = ({ data, rootConceptId, conceptId, depth, search, +}: { + rootConceptId: ConceptIdT; + conceptId: ConceptIdT; + data: ConceptT; + depth: number; + search: SearchT; }) => { const { open, onToggleOpen } = useOpenableConcept({ conceptId, @@ -121,6 +88,10 @@ const ConceptTreeNode: FC = ({ matchingEntities: data.matchingEntities, dateRange: data.dateRange, + excludeTimestamps: + root.excludeFromTimeAggregation || + data.excludeFromTimeAggregation, + tree: rootConceptId, }; }} @@ -140,7 +111,7 @@ const ConceptTreeNode: FC = ({ key={childId} rootConceptId={rootConceptId} conceptId={childId} - data={selectTreeNodeData(child)} + data={child} depth={depth + 1} search={search} /> diff --git a/frontend/src/js/dataset/actions.ts b/frontend/src/js/dataset/actions.ts index 3e7287e561..caa4aee6f3 100644 --- a/frontend/src/js/dataset/actions.ts +++ b/frontend/src/js/dataset/actions.ts @@ -9,8 +9,12 @@ import { StateT } from "../app/reducers"; import { ErrorObject } from "../common/actions/genericActions"; import { exists } from "../common/helpers/exists"; import { useLoadTrees } from "../concept-trees/actions"; -import { useLoadDefaultHistoryParams } from "../entity-history/actions"; +import { + resetHistory, + useLoadDefaultHistoryParams, +} from "../entity-history/actions"; import { useLoadQueries } from "../previous-queries/list/actions"; +import { queryResultReset } from "../query-runner/actions"; import { setMessage } from "../snack-message/actions"; import { SnackMessageType } from "../snack-message/reducer"; import { clearQuery, loadSavedQuery } from "../standard-query-editor/actions"; @@ -109,6 +113,12 @@ export const useSelectDataset = () => { dispatch(selectDatasetInput({ id: datasetId })); + dispatch(resetHistory()); + dispatch(queryResultReset({ queryType: "standard" })); + dispatch(queryResultReset({ queryType: "timebased" })); + dispatch(queryResultReset({ queryType: "editorV2" })); + dispatch(queryResultReset({ queryType: "externalForms" })); + // To allow loading trees to check whether they should abort or not setDatasetId(datasetId); diff --git a/frontend/src/js/editor-v2/EditorLayout.ts b/frontend/src/js/editor-v2/EditorLayout.ts new file mode 100644 index 0000000000..3cb8d5902e --- /dev/null +++ b/frontend/src/js/editor-v2/EditorLayout.ts @@ -0,0 +1,24 @@ +import styled from "@emotion/styled"; + +export const Grid = styled("div")` + flex-grow: 1; + display: grid; + gap: 3px; + height: 100%; + width: 100%; + place-items: center; + overflow: auto; +`; + +export const Connector = styled("span")` + text-transform: uppercase; + font-size: ${({ theme }) => theme.font.sm}; + color: black; + user-select: none; + + border-radius: ${({ theme }) => theme.borderRadius}; + padding: 0px 5px; + display: flex; + justify-content: center; + align-items: center; +`; diff --git a/frontend/src/js/editor-v2/EditorV2.tsx b/frontend/src/js/editor-v2/EditorV2.tsx new file mode 100644 index 0000000000..4eff775dd8 --- /dev/null +++ b/frontend/src/js/editor-v2/EditorV2.tsx @@ -0,0 +1,485 @@ +import styled from "@emotion/styled"; +import { faCalendar, faTrashCan } from "@fortawesome/free-regular-svg-icons"; +import { + faBan, + faCircleNodes, + faEdit, + faExpandArrowsAlt, + faHourglass, + faRefresh, + faTrash, +} from "@fortawesome/free-solid-svg-icons"; +import { createId } from "@paralleldrive/cuid2"; +import { useCallback, useMemo, useState } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; +import { useTranslation } from "react-i18next"; + +import IconButton from "../button/IconButton"; +import { nodeIsConceptQueryNode } from "../model/node"; +import { EmptyQueryEditorDropzone } from "../standard-query-editor/EmptyQueryEditorDropzone"; +import { + DragItemConceptTreeNode, + DragItemQuery, +} from "../standard-query-editor/types"; +import { ConfirmableTooltip } from "../tooltip/ConfirmableTooltip"; +import WithTooltip from "../tooltip/WithTooltip"; +import Dropzone from "../ui-components/Dropzone"; + +import { Connector, Grid } from "./EditorLayout"; +import { EditorV2QueryRunner } from "./EditorV2QueryRunner"; +import { KeyboardShortcutTooltip } from "./KeyboardShortcutTooltip"; +import { TreeNode } from "./TreeNode"; +import { EDITOR_DROP_TYPES, HOTKEYS } from "./config"; +import { useConnectorEditing } from "./connector-update/useConnectorRotation"; +import { DateModal } from "./date-restriction/DateModal"; +import { useDateEditing } from "./date-restriction/useDateEditing"; +import { useExpandQuery } from "./expand/useExpandQuery"; +import { useNegationEditing } from "./negation/useNegationEditing"; +import { EditorV2QueryNodeEditor } from "./query-node-edit/EditorV2QueryNodeEditor"; +import { useQueryNodeEditing } from "./query-node-edit/useQueryNodeEditing"; +import { TimeConnectionModal } from "./time-connection/TimeConnectionModal"; +import { useTimeConnectionEditing } from "./time-connection/useTimeConnectionEditing"; +import { Tree, TreeChildrenTime } from "./types"; +import { findNodeById, useGetTranslatedConnection } from "./util"; + +const Root = styled("div")` + flex-grow: 1; + height: 100%; + display: flex; + flex-direction: column; +`; + +const Main = styled("div")` + flex-grow: 1; + height: 100%; + padding: 8px 10px 10px 10px; + display: flex; + flex-direction: column; + gap: 10px; +`; + +const SxDropzone = styled(Dropzone)` + width: 100%; + height: 100%; +`; + +const Actions = styled("div")` + display: flex; + align-items: center; + justify-content: space-between; +`; + +const Flex = styled("div")` + display: flex; + align-items: center; +`; + +const SxIconButton = styled(IconButton)` + display: flex; + align-items: center; + gap: 5px; +`; + +const useEditorState = () => { + const [tree, setTree] = useState(undefined); + const [selectedNodeId, setSelectedNodeId] = useState( + undefined, + ); + const selectedNode = useMemo(() => { + if (!tree || !selectedNodeId) { + return undefined; + } + return findNodeById(tree, selectedNodeId); + }, [tree, selectedNodeId]); + + const onReset = useCallback(() => { + setTree(undefined); + }, []); + + const updateTreeNode = useCallback( + (id: string, update: (node: Tree) => void) => { + const newTree = JSON.parse(JSON.stringify(tree)); + const node = findNodeById(newTree, id); + if (node) { + update(node); + setTree(newTree); + } + }, + [tree], + ); + + return { + tree, + setTree, + updateTreeNode, + onReset, + selectedNode, + setSelectedNodeId, + }; +}; + +export function EditorV2({ + featureDates, + featureNegate, + featureExpand, + featureConnectorRotate, + featureQueryNodeEdit, + featureContentInfos, + featureTimebasedQueries, +}: { + featureDates: boolean; + featureNegate: boolean; + featureExpand: boolean; + featureConnectorRotate: boolean; + featureQueryNodeEdit: boolean; + featureContentInfos: boolean; + featureTimebasedQueries: boolean; +}) { + const { t } = useTranslation(); + const { + tree, + setTree, + updateTreeNode, + onReset, + selectedNode, + setSelectedNodeId, + } = useEditorState(); + + const onFlip = useCallback(() => { + if (!selectedNode || !selectedNode.children) return; + + updateTreeNode(selectedNode.id, (node) => { + if (!node.children) return; + + node.children.direction = + node.children.direction === "horizontal" ? "vertical" : "horizontal"; + }); + }, [selectedNode, updateTreeNode]); + + const onDelete = useCallback(() => { + if (!selectedNode) return; + + if (selectedNode.parentId === undefined) { + setTree(undefined); + } else { + updateTreeNode(selectedNode.parentId, (parent) => { + if (!parent.children) return; + + parent.children.items = parent.children.items.filter( + (item) => item.id !== selectedNode.id, + ); + + if (parent.children.items.length === 1) { + const child = parent.children.items[0]; + parent.id = child.id; + parent.children = child.children; + parent.data = child.data; + parent.dates ||= child.dates; + parent.negation ||= child.negation; + } + }); + } + }, [selectedNode, setTree, updateTreeNode]); + + useHotkeys(HOTKEYS.delete.keyname, onDelete, [onDelete]); + useHotkeys(HOTKEYS.flip.keyname, onFlip, [onFlip]); + useHotkeys(HOTKEYS.reset.keyname, onReset, [onReset]); + + const { canExpand, onExpand } = useExpandQuery({ + enabled: featureExpand, + hotkey: "x", + updateTreeNode, + selectedNode, + setSelectedNodeId, + tree, + }); + + const { showModal, headline, onOpen, onClose } = useDateEditing({ + enabled: featureDates, + hotkey: "d", + selectedNode, + }); + + const { onNegateClick } = useNegationEditing({ + enabled: featureNegate, + hotkey: HOTKEYS.negate.keyname, + selectedNode, + updateTreeNode, + }); + + const { onRotateConnector } = useConnectorEditing({ + enabled: featureConnectorRotate, + timebasedQueriesEnabled: featureTimebasedQueries, + hotkey: HOTKEYS.rotateConnector.keyname, + selectedNode, + updateTreeNode, + }); + + const { + showModal: showTimeModal, + onOpen: onOpenTimeModal, + onClose: onCloseTimeModal, + } = useTimeConnectionEditing({ + enabled: featureTimebasedQueries, + hotkey: HOTKEYS.editTimeConnection.keyname, + selectedNode, + }); + + const { + showModal: showQueryNodeEditor, + onOpen: onOpenQueryNodeEditor, + onClose: onCloseQueryNodeEditor, + } = useQueryNodeEditing({ + enabled: featureQueryNodeEdit, + hotkey: HOTKEYS.editQueryNode.keyname, + selectedNode, + }); + + const getTranslatedConnection = useGetTranslatedConnection(); + const connection = getTranslatedConnection( + selectedNode?.children?.connection, + ); + + const onChangeData = useCallback( + (data: DragItemConceptTreeNode) => { + if (!selectedNode) return; + updateTreeNode(selectedNode.id, (node) => { + node.data = data; + }); + }, + [selectedNode, updateTreeNode], + ); + + return ( + +
+ {showQueryNodeEditor && + selectedNode?.data && + nodeIsConceptQueryNode(selectedNode.data) && ( + + )} + {showModal && selectedNode && ( + { + updateTreeNode(selectedNode.id, (node) => { + if (!node.dates) node.dates = {}; + node.dates.excluded = excluded; + }); + }} + onResetDates={() => + updateTreeNode(selectedNode.id, (node) => { + if (!node.dates) return; + node.dates.restriction = undefined; + }) + } + setDateRange={(dateRange) => { + updateTreeNode(selectedNode.id, (node) => { + if (!node.dates) node.dates = {}; + node.dates.restriction = dateRange; + }); + }} + /> + )} + {showTimeModal && selectedNode && ( + { + updateTreeNode(selectedNode.id, (node) => { + node.children = nodeChildren; + }); + }} + /> + )} + {tree && ( + + + {featureQueryNodeEdit && + selectedNode?.data && + nodeIsConceptQueryNode(selectedNode.data) && ( + + { + e.stopPropagation(); + onOpenQueryNodeEditor(); + }} + > + {t("editorV2.edit")} + + + )} + {featureDates && selectedNode && ( + + { + e.stopPropagation(); + onOpen(); + }} + > + {t("editorV2.dates")} + + + )} + {featureNegate && selectedNode && ( + + { + e.stopPropagation(); + onNegateClick(); + }} + > + {t("editorV2.negate")} + + + )} + {featureConnectorRotate && selectedNode?.children && ( + + { + e.stopPropagation(); + onRotateConnector(); + }} + > + {connection} + + + )} + {selectedNode?.children?.connection === "time" && ( + + { + e.stopPropagation(); + onOpenTimeModal(); + }} + > + {t("editorV2.timeConnection")} + + + )} + {canExpand && ( + + { + e.stopPropagation(); + onExpand(); + }} + > + {t("editorV2.expand")} + + + )} + + + {selectedNode?.children && ( + + { + e.stopPropagation(); + onFlip(); + }} + > + {t("editorV2.flip")} + + + )} + {selectedNode && ( + + { + e.stopPropagation(); + onDelete(); + }} + > + {t("editorV2.delete")} + + + )} + + + + + + + + )} + { + if (!selectedNode || showModal) return; + setSelectedNodeId(undefined); + }} + > + {tree ? ( + + ) : ( + { + const id = createId(); + setTree({ + id, + data: item as DragItemConceptTreeNode | DragItemQuery, + }); + setSelectedNodeId(id); + }} + acceptedDropTypes={EDITOR_DROP_TYPES} + > + {() => } + + )} + +
+ +
+ ); +} diff --git a/frontend/src/js/editor-v2/EditorV2QueryRunner.tsx b/frontend/src/js/editor-v2/EditorV2QueryRunner.tsx new file mode 100644 index 0000000000..432e007845 --- /dev/null +++ b/frontend/src/js/editor-v2/EditorV2QueryRunner.tsx @@ -0,0 +1,43 @@ +import { useSelector } from "react-redux"; + +import { StateT } from "../app/reducers"; +import { useDatasetId } from "../dataset/selectors"; +import QueryRunner from "../query-runner/QueryRunner"; +import { useStartQuery, useStopQuery } from "../query-runner/actions"; +import { QueryRunnerStateT } from "../query-runner/reducer"; + +import { EditorV2Query } from "./types"; + +export const EditorV2QueryRunner = ({ query }: { query: EditorV2Query }) => { + const datasetId = useDatasetId(); + const queryRunner = useSelector( + (state) => state.editorV2QueryRunner, + ); + const startStandardQuery = useStartQuery("editorV2"); + const stopStandardQuery = useStopQuery("editorV2"); + + const startQuery = () => { + if (datasetId) { + startStandardQuery(datasetId, query); + } + }; + + const queryId = queryRunner.runningQuery; + const stopQuery = () => { + if (queryId) { + stopStandardQuery(queryId); + } + }; + + const disabled = !query.tree; + + return ( + + ); +}; diff --git a/frontend/src/js/editor-v2/KeyboardShortcutTooltip.tsx b/frontend/src/js/editor-v2/KeyboardShortcutTooltip.tsx new file mode 100644 index 0000000000..059894767f --- /dev/null +++ b/frontend/src/js/editor-v2/KeyboardShortcutTooltip.tsx @@ -0,0 +1,50 @@ +import styled from "@emotion/styled"; +import { Fragment, ReactElement } from "react"; +import { useTranslation } from "react-i18next"; + +import { KeyboardKey } from "../common/components/KeyboardKey"; +import WithTooltip from "../tooltip/WithTooltip"; + +const KeyTooltip = styled("div")` + padding: 8px 15px; + display: flex; + align-items: center; + gap: 5px; +`; + +const Keys = styled("div")` + display: flex; + align-items: center; + gap: 2px; +`; + +export const KeyboardShortcutTooltip = ({ + keyname, + children, +}: { + keyname: string; + children: ReactElement; +}) => { + const { t } = useTranslation(); + const keynames = keyname.split("+"); + + return ( + + {t("common.shortcut")}:{" "} + + {keynames.map((keyPart, i) => ( + + {keyPart} + {i < keynames.length - 1 && "+"} + + ))} + + + } + > + {children} + + ); +}; diff --git a/frontend/src/js/editor-v2/TreeNode.tsx b/frontend/src/js/editor-v2/TreeNode.tsx new file mode 100644 index 0000000000..9a7be0351e --- /dev/null +++ b/frontend/src/js/editor-v2/TreeNode.tsx @@ -0,0 +1,425 @@ +import styled from "@emotion/styled"; +import { faCalendarMinus } from "@fortawesome/free-regular-svg-icons"; +import { createId } from "@paralleldrive/cuid2"; +import { DOMAttributes, memo } from "react"; +import { useTranslation } from "react-i18next"; + +import { DNDType } from "../common/constants/dndTypes"; +import { Icon } from "../icon/FaIcon"; +import { nodeIsConceptQueryNode, useActiveState } from "../model/node"; +import { getRootNodeLabel } from "../standard-query-editor/helper"; +import WithTooltip from "../tooltip/WithTooltip"; +import Dropzone, { DropzoneProps } from "../ui-components/Dropzone"; + +import { Connector, Grid } from "./EditorLayout"; +import { TreeNodeConcept } from "./TreeNodeConcept"; +import { EDITOR_DROP_TYPES } from "./config"; +import { DateRange } from "./date-restriction/DateRange"; +import { TimeConnection } from "./time-connection/TimeConnection"; +import { ConnectionKind, Tree } from "./types"; +import { useGetTranslatedConnection } from "./util"; + +const NodeContainer = styled("div")` + display: grid; + gap: 5px; +`; + +const Node = styled("div")<{ + selected?: boolean; + active?: boolean; + negated?: boolean; + leaf?: boolean; + isDragging?: boolean; +}>` + padding: ${({ leaf, isDragging }) => + leaf ? "8px 10px" : isDragging ? "5px" : "24px"}; + border: 2px solid + ${({ negated, theme, selected, active }) => + negated + ? theme.col.red + : active + ? theme.col.blueGrayDark + : selected + ? theme.col.gray + : theme.col.grayMediumLight}; + box-shadow: ${({ selected, theme }) => + selected ? `inset 0px 0px 0px 4px ${theme.col.blueGrayVeryLight}` : "none"}; + + border-radius: ${({ theme }) => theme.borderRadius}; + width: ${({ leaf }) => (leaf ? "230px" : "inherit")}; + background-color: ${({ leaf, theme }) => (leaf ? "white" : theme.col.bg)}; + cursor: pointer; + display: flex; + flex-direction: column; + gap: 10px; +`; + +function getGridStyles(tree: Tree) { + if (!tree.children) { + return {}; + } + + if (tree.children.direction === "horizontal") { + return { + gridAutoFlow: "column", + }; + } else { + return { + gridTemplateColumns: "1fr", + }; + } +} + +const InvisibleDropzoneContainer = styled(Dropzone)<{ bare?: boolean }>` + width: 100%; + height: 100%; + padding: ${({ bare }) => (bare ? "6px" : "20px")}; +`; + +const InvisibleDropzone = ( + props: Omit, "acceptedDropTypes">, +) => { + return ( + + ); +}; + +const Name = styled("div")` + font-size: ${({ theme }) => theme.font.sm}; + font-weight: 400; + color: ${({ theme }) => theme.col.black}; +`; + +const PreviousQueryLabel = styled("p")` + margin: 0; + line-height: 1.2; + font-size: ${({ theme }) => theme.font.xs}; + text-transform: uppercase; + font-weight: 700; + color: ${({ theme }) => theme.col.blueGrayDark}; +`; + +const ContentContainer = styled("div")` + display: flex; + flex-direction: column; + gap: 4px; +`; + +const RootNode = styled("p")` + margin: 0; + line-height: 1; + text-transform: uppercase; + font-weight: 700; + font-size: ${({ theme }) => theme.font.xs}; + color: ${({ theme }) => theme.col.blueGrayDark}; + word-break: break-word; +`; + +const Dates = styled("div")` + text-align: right; + font-size: ${({ theme }) => theme.font.xs}; + text-transform: uppercase; + font-weight: 400; +`; + +export function TreeNode({ + tree, + treeParent, + updateTreeNode, + droppable, + selectedNode, + setSelectedNodeId, + featureContentInfos, + onOpenQueryNodeEditor, + onOpenTimeModal, + onRotateConnector, +}: { + tree: Tree; + treeParent?: Tree; + updateTreeNode: (id: string, update: (node: Tree) => void) => void; + droppable: { + h: boolean; + v: boolean; + }; + selectedNode: Tree | undefined; + setSelectedNodeId: (id: Tree["id"] | undefined) => void; + featureContentInfos?: boolean; + onOpenQueryNodeEditor?: () => void; + onOpenTimeModal?: () => void; + onRotateConnector?: () => void; +}) { + const gridStyles = getGridStyles(tree); + + const { t } = useTranslation(); + + const rootNodeLabel = tree.data ? getRootNodeLabel(tree.data) : null; + + const { active, tooltipText } = useActiveState(tree.data); + + const onDropOutsideOfNode = ({ + pos, + direction, + item, + }: { + direction: "h" | "v"; + pos: "b" | "a"; + item: any; + }) => { + // Create a new "parent" and create a new "item", make parent contain tree and item + const newParentId = createId(); + const newItemId = createId(); + + updateTreeNode(tree.id, (node) => { + const newChildren: Tree[] = [ + { + id: newItemId, + negation: false, + data: item, + parentId: newParentId, + }, + { + ...tree, + parentId: newParentId, + }, + ]; + + node.id = newParentId; + node.data = undefined; + node.dates = undefined; + node.negation = false; + + const connection = + treeParent?.children?.connection || tree.children?.connection; + + node.children = { + connection: connection === "and" ? "or" : "and" || "and", + direction: direction === "h" ? "horizontal" : "vertical", + items: pos === "b" ? newChildren : newChildren.reverse(), + }; + }); + setSelectedNodeId(newItemId); + }; + + const onDropAtChildrenIdx = ({ idx, item }: { idx: number; item: any }) => { + const newItemId = createId(); + // Create a new "item" and insert it at idx of tree.children + updateTreeNode(tree.id, (node) => { + if (node.children) { + node.children.items.splice(idx, 0, { + id: newItemId, + negation: false, + data: item, + parentId: node.id, + }); + } + }); + setSelectedNodeId(newItemId); + }; + + return ( + + {droppable.v && ( + + onDropOutsideOfNode({ pos: "b", direction: "v", item }) + } + /> + )} + + {droppable.h && ( + + onDropOutsideOfNode({ + pos: "b", + direction: "h", + item, + }) + } + /> + )} + {}} + > + {({ canDrop }) => ( + + { + if (tree.data && nodeIsConceptQueryNode(tree.data)) { + e.stopPropagation(); + onOpenQueryNodeEditor?.(); + } + }} + onClick={(e) => { + e.stopPropagation(); + setSelectedNodeId(tree.id); + }} + > + {tree.children && tree.children.connection === "time" && ( + { + e.stopPropagation(); + onOpenTimeModal?.(); + }} + /> + )} + {tree.dates?.restriction && ( + + + + )} + {tree.dates?.excluded && ( + + + {t("editorV2.datesExcluded")} + + )} + {(!tree.children || tree.data) && ( + + {tree.data?.type !== DNDType.CONCEPT_TREE_NODE && ( + + {t("queryEditor.previousQuery")} + + )} + {rootNodeLabel && {rootNodeLabel}} + {tree.data?.label && {tree.data.label}} + {tree.data && nodeIsConceptQueryNode(tree.data) && ( + + )} + + )} + {tree.children && ( + + onDropAtChildrenIdx({ idx: 0, item })} + > + {() => ( + + )} + + {tree.children.items.map((item, i, items) => ( + <> + + {i < items.length - 1 && ( + + onDropAtChildrenIdx({ idx: i + 1, item }) + } + > + {() => ( + { + e.stopPropagation(); + onRotateConnector?.(); + }} + connection={tree.children?.connection} + /> + )} + + )} + + ))} + + onDropAtChildrenIdx({ + idx: tree.children!.items.length, + item, + }) + } + > + {() => ( + + )} + + + )} + + + )} + + {droppable.h && ( + + onDropOutsideOfNode({ pos: "a", direction: "h", item }) + } + /> + )} + + {droppable.v && ( + + onDropOutsideOfNode({ pos: "a", direction: "v", item }) + } + /> + )} + + ); +} + +const Connection = memo( + ({ + connection, + onDoubleClick, + }: { + connection?: ConnectionKind; + onDoubleClick?: DOMAttributes["onDoubleClick"]; + }) => { + const getTranslatedConnection = useGetTranslatedConnection(); + + return ( + + {getTranslatedConnection(connection)} + + ); + }, +); diff --git a/frontend/src/js/editor-v2/TreeNodeConcept.tsx b/frontend/src/js/editor-v2/TreeNodeConcept.tsx new file mode 100644 index 0000000000..72a81a71b0 --- /dev/null +++ b/frontend/src/js/editor-v2/TreeNodeConcept.tsx @@ -0,0 +1,145 @@ +import styled from "@emotion/styled"; +import { Fragment } from "react"; +import { useTranslation } from "react-i18next"; + +import { exists } from "../common/helpers/exists"; +import { DragItemConceptTreeNode } from "../standard-query-editor/types"; + +const Bold = styled("span")` + font-weight: 400; +`; + +const SectionHeading = styled("h4")` + font-weight: 700; + color: ${(props) => props.theme.col.blueGrayDark}; + margin: 0; + text-transform: uppercase; + font-size: ${({ theme }) => theme.font.xs}; +`; + +const Appendix = styled("div")` + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 8px; +`; + +const Description = styled("div")` + font-size: ${({ theme }) => theme.font.xs}; + color: ${({ theme }) => theme.col.black}; + display: flex; + align-items: center; + gap: 0px 5px; + flex-wrap: wrap; +`; + +export const TreeNodeConcept = ({ + node, + featureContentInfos, +}: { + node: DragItemConceptTreeNode; + featureContentInfos?: boolean; +}) => { + const { t } = useTranslation(); + const selectedSelects = [ + ...node.selects, + ...node.tables.flatMap((t) => t.selects), + ].filter((s) => s.selected); + + const filtersWithValues = node.tables.flatMap((t) => + t.filters.filter( + (f) => + exists(f.value) && (!(f.value instanceof Array) || f.value.length > 0), + ), + ); + + const showAppendix = + featureContentInfos && + (selectedSelects.length > 0 || filtersWithValues.length > 0); + + return ( + <> + {node.description && {node.description}} + {showAppendix && ( + + {selectedSelects.length > 0 && ( +
+ {t("editorV2.outputSection")} + + + +
+ )} + {filtersWithValues.length > 0 && ( +
+ {t("editorV2.filtersSection")} + {filtersWithValues.map((f) => ( + + {f.label}: + + + ))} +
+ )} +
+ )} + + ); +}; + +const Value = ({ + value, + isElement, +}: { + value: unknown; + isElement?: boolean; +}) => { + if (typeof value === "string" || typeof value === "number") { + return ( + + {value} + {isElement && ","} + + ); + } else if (typeof value === "boolean") { + return {value ? "✔" : "✗"}; + } else if (value instanceof Array) { + return ( + <> + {value.slice(0, 10).map((v, idx) => ( + <> + + + ))} + {value.length > 10 && {`... +${value.length - 10}`}} + + ); + } else if ( + value instanceof Object && + "label" in value && + typeof value.label === "string" + ) { + return ( + + {value.label} + {isElement && ","} + + ); + } else if (value instanceof Object) { + return ( + <> + {Object.entries(value) + .filter(([, v]) => exists(v)) + .map(([k, v]) => ( + + {k}: + + ))} + + ); + } else if (value === null) { + return ; + } else { + return {JSON.stringify(value)}; + } +}; diff --git a/frontend/src/js/editor-v2/config.ts b/frontend/src/js/editor-v2/config.ts new file mode 100644 index 0000000000..1ce14e5394 --- /dev/null +++ b/frontend/src/js/editor-v2/config.ts @@ -0,0 +1,19 @@ +import { DNDType } from "../common/constants/dndTypes"; + +export const EDITOR_DROP_TYPES = [ + DNDType.CONCEPT_TREE_NODE, + DNDType.PREVIOUS_QUERY, + DNDType.PREVIOUS_SECONDARY_ID_QUERY, +]; + +export const HOTKEYS = { + expand: { keyname: "x" }, + negate: { keyname: "n" }, + editDates: { keyname: "d" }, + delete: { keyname: ["backspace", "del"] }, + flip: { keyname: "f" }, + rotateConnector: { keyname: "c" }, + editTimeConnection: { keyname: "t" }, + reset: { keyname: "shift+backspace" }, + editQueryNode: { keyname: "Enter" }, +}; diff --git a/frontend/src/js/editor-v2/connector-update/useConnectorRotation.ts b/frontend/src/js/editor-v2/connector-update/useConnectorRotation.ts new file mode 100644 index 0000000000..cc44406fc6 --- /dev/null +++ b/frontend/src/js/editor-v2/connector-update/useConnectorRotation.ts @@ -0,0 +1,66 @@ +import { useCallback } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; + +import { ConnectionKind, Tree, TreeChildren } from "../types"; + +const CONNECTIONS = ["and", "or", "time"] as ConnectionKind[]; + +const getNextConnector = ( + children: TreeChildren, + timebasedQueriesEnabled: boolean, +) => { + const allowedConnectors = timebasedQueriesEnabled + ? CONNECTIONS + : CONNECTIONS.filter((c) => c !== "time"); + + const index = allowedConnectors.indexOf(children.connection); + + const nextConnector = + allowedConnectors[(index + 1) % allowedConnectors.length]; + + if (nextConnector !== "time") { + return { + items: children.items, + direction: children.direction, + connection: nextConnector, + }; + } else { + return { + items: children.items, + direction: children.direction, + connection: "time" as const, + timestamps: children.items.map(() => "some" as const), + operator: "before" as const, + }; + } +}; + +export const useConnectorEditing = ({ + enabled, + timebasedQueriesEnabled, + hotkey, + selectedNode, + updateTreeNode, +}: { + enabled: boolean; + timebasedQueriesEnabled: boolean; + hotkey: string; + selectedNode: Tree | undefined; + updateTreeNode: (id: string, update: (node: Tree) => void) => void; +}) => { + const onRotateConnector = useCallback(() => { + if (!enabled || !selectedNode || !selectedNode.children) return; + + updateTreeNode(selectedNode.id, (node) => { + if (!node.children) return; + + node.children = getNextConnector(node.children, timebasedQueriesEnabled); + }); + }, [enabled, selectedNode, updateTreeNode, timebasedQueriesEnabled]); + + useHotkeys(hotkey, onRotateConnector, [onRotateConnector]); + + return { + onRotateConnector, + }; +}; diff --git a/frontend/src/js/editor-v2/date-restriction/DateModal.tsx b/frontend/src/js/editor-v2/date-restriction/DateModal.tsx new file mode 100644 index 0000000000..27da9d25a9 --- /dev/null +++ b/frontend/src/js/editor-v2/date-restriction/DateModal.tsx @@ -0,0 +1,116 @@ +import styled from "@emotion/styled"; +import { faCalendarMinus } from "@fortawesome/free-regular-svg-icons"; +import { faUndo } from "@fortawesome/free-solid-svg-icons"; +import { useCallback, useMemo } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; +import { useTranslation } from "react-i18next"; + +import { DateRangeT } from "../../api/types"; +import IconButton from "../../button/IconButton"; +import { DateStringMinMax } from "../../common/helpers/dateHelper"; +import { Icon } from "../../icon/FaIcon"; +import Modal from "../../modal/Modal"; +import InputCheckbox from "../../ui-components/InputCheckbox"; +import InputDateRange from "../../ui-components/InputDateRange"; + +const Col = styled("div")` + display: flex; + flex-direction: column; + gap: 32px; +`; + +const SectionHeadline = styled("p")` + display: flex; + align-items: center; + gap: 10px; + margin: 0 0 10px; + font-size: ${({ theme }) => theme.font.md}; + font-weight: 400; +`; + +const ResetAll = styled(IconButton)` + color: ${({ theme }) => theme.col.blueGrayDark}; + font-weight: 700; + margin-left: 20px; +`; + +export const DateModal = ({ + onClose, + dateRange = {}, + headline, + excludeFromDates, + setExcludeFromDates, + setDateRange, + onResetDates, +}: { + onClose: () => void; + excludeFromDates?: boolean; + setExcludeFromDates: (exclude: boolean) => void; + dateRange?: DateRangeT; + setDateRange: (range: DateRangeT) => void; + headline: string; + onResetDates: () => void; +}) => { + const { t } = useTranslation(); + + useHotkeys("esc", onClose, [onClose]); + + const minDate = dateRange ? dateRange.min || null : null; + const maxDate = dateRange ? dateRange.max || null : null; + const hasActiveDate = !!(minDate || maxDate); + + const labelSuffix = useMemo(() => { + return hasActiveDate ? ( + + {t("queryNodeEditor.reset")} + + ) : null; + }, [t, hasActiveDate, onResetDates]); + + const onChange = useCallback( + (date: DateStringMinMax) => { + if (!date.min && !date.max) return; + + setDateRange({ + min: date.min || undefined, + max: date.max || undefined, + }); + }, + [setDateRange], + ); + + return ( + + +
{headline}
+ +
+ + + {t("queryNodeEditor.excludeTimestamps")} + + +
+ +
+ ); +}; diff --git a/frontend/src/js/editor-v2/date-restriction/DateRange.tsx b/frontend/src/js/editor-v2/date-restriction/DateRange.tsx new file mode 100644 index 0000000000..f420539f06 --- /dev/null +++ b/frontend/src/js/editor-v2/date-restriction/DateRange.tsx @@ -0,0 +1,55 @@ +import styled from "@emotion/styled"; +import { useTranslation } from "react-i18next"; + +import { DateRangeT } from "../../api/types"; +import { formatDate } from "../../common/helpers/dateHelper"; + +const Root = styled("div")` + font-size: ${({ theme }) => theme.font.xs}; + font-family: monospace; + display: inline-grid; + gap: 0 5px; + grid-template-columns: auto 1fr; +`; + +const Label = styled("div")` + text-transform: uppercase; + color: ${({ theme }) => theme.col.blueGrayDark}; + font-weight: 700; + justify-self: flex-end; +`; + +const getFormattedDate = (date: string | undefined, dateFormat: string) => { + if (!date) return null; + + const d = new Date(date); + + if (isNaN(d.getTime())) return null; + + return formatDate(d, dateFormat); +}; + +export const DateRange = ({ dateRange }: { dateRange: DateRangeT }) => { + const { t } = useTranslation(); + const dateFormat = t("inputDateRange.dateFormat"); + + const dateMin = getFormattedDate(dateRange.min, dateFormat); + const dateMax = getFormattedDate(dateRange.max, dateFormat); + + return ( + + {dateMin && ( + <> + + {dateMin} + + )} + {dateMax && dateMax !== dateMin && ( + <> + + {dateMax} + + )} + + ); +}; diff --git a/frontend/src/js/editor-v2/date-restriction/useDateEditing.ts b/frontend/src/js/editor-v2/date-restriction/useDateEditing.ts new file mode 100644 index 0000000000..5ce236992e --- /dev/null +++ b/frontend/src/js/editor-v2/date-restriction/useDateEditing.ts @@ -0,0 +1,44 @@ +import { useState, useCallback, useMemo } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; + +import { Tree } from "../types"; + +export const useDateEditing = ({ + enabled, + hotkey, + selectedNode, +}: { + enabled: boolean; + hotkey: string; + selectedNode: Tree | undefined; +}) => { + const [showModal, setShowModal] = useState(false); + + const onClose = useCallback(() => setShowModal(false), []); + const onOpen = useCallback(() => { + if (!enabled) return; + if (!selectedNode) return; + + setShowModal(true); + }, [enabled, selectedNode]); + + useHotkeys(hotkey, onOpen, [onOpen], { + preventDefault: true, + }); + + const headline = useMemo(() => { + if (!selectedNode) return ""; + + return ( + selectedNode.data?.label || + (selectedNode.children?.items || []).map((c) => c.data?.label).join(" - ") + ); + }, [selectedNode]); + + return { + showModal, + headline, + onClose, + onOpen, + }; +}; diff --git a/frontend/src/js/editor-v2/expand/useExpandQuery.ts b/frontend/src/js/editor-v2/expand/useExpandQuery.ts new file mode 100644 index 0000000000..d7ffe635e6 --- /dev/null +++ b/frontend/src/js/editor-v2/expand/useExpandQuery.ts @@ -0,0 +1,206 @@ +import { createId } from "@paralleldrive/cuid2"; +import { useCallback, useMemo } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; +import { useSelector } from "react-redux"; + +import { useGetQuery } from "../../api/api"; +import { + AndNodeT, + DateRestrictionNodeT, + OrNodeT, + NegationNodeT, + QueryConceptNodeT, + SavedQueryNodeT, + DateRangeT, +} from "../../api/types"; +import { StateT } from "../../app/reducers"; +import { DNDType } from "../../common/constants/dndTypes"; +import { getConceptsByIdsWithTablesAndSelects } from "../../concept-trees/globalTreeStoreHelper"; +import { TreesT } from "../../concept-trees/reducer"; +import { mergeFromSavedConceptIntoNode } from "../../standard-query-editor/expandNode"; +import { + DragItemConceptTreeNode, + DragItemQuery, +} from "../../standard-query-editor/types"; +import { Tree } from "../types"; +import { findNodeById } from "../util"; + +export const useExpandQuery = ({ + selectedNode, + hotkey, + enabled, + tree, + setSelectedNodeId, + updateTreeNode, +}: { + enabled: boolean; + hotkey: string; + selectedNode?: Tree; + setSelectedNodeId: (id: Tree["id"] | undefined) => void; + tree?: Tree; + updateTreeNode: (id: string, update: (node: Tree) => void) => void; +}) => { + const rootConcepts = useSelector( + (state) => state.conceptTrees.trees, + ); + const expandNode = useCallback( + ( + queryNode: + | AndNodeT + | DateRestrictionNodeT + | OrNodeT + | NegationNodeT + | QueryConceptNodeT + | SavedQueryNodeT, + config: { + parentId?: string; + negation?: boolean; + dateRestriction?: DateRangeT; + } = {}, + ): Tree => { + switch (queryNode.type) { + case "AND": + if (queryNode.children.length === 1) { + return expandNode(queryNode.children[0], config); + } + const andid = createId(); + return { + id: andid, + ...config, + children: { + connection: "and", + direction: "horizontal", + items: queryNode.children.map((child) => + expandNode(child, { parentId: andid }), + ), + }, + }; + case "OR": + if (queryNode.children.length === 1) { + return expandNode(queryNode.children[0], config); + } + const orid = createId(); + return { + id: orid, + ...config, + children: { + connection: "or", + direction: "vertical", + items: queryNode.children.map((child) => + expandNode(child, { parentId: orid }), + ), + }, + }; + case "NEGATION": + return expandNode(queryNode.child, { ...config, negation: true }); + case "DATE_RESTRICTION": + return expandNode(queryNode.child, { + ...config, + dateRestriction: queryNode.dateRange, + }); + case "CONCEPT": + const lookupResult = getConceptsByIdsWithTablesAndSelects( + rootConcepts, + queryNode.ids, + { useDefaults: false }, + ); + if (!lookupResult) { + throw new Error("Concept not found"); + } + const { tables, selects } = mergeFromSavedConceptIntoNode(queryNode, { + tables: lookupResult.tables, + selects: lookupResult.selects || [], + }); + const label = queryNode.label || lookupResult.concepts[0].label; + const description = lookupResult.concepts[0].description; + + const dataNode: DragItemConceptTreeNode = { + ...queryNode, + dragContext: { width: 0, height: 0 }, + additionalInfos: lookupResult.concepts[0].additionalInfos, + matchingEntities: lookupResult.concepts[0].matchingEntities, + matchingEntries: lookupResult.concepts[0].matchingEntries, + type: DNDType.CONCEPT_TREE_NODE, + label, + description, + tables, + selects, + tree: lookupResult.root, + }; + + return { + id: createId(), + data: dataNode, + dates: config.dateRestriction + ? { + ...config.dateRestriction, + ...(queryNode.excludeFromTimeAggregation + ? { excluded: true } + : {}), + } + : undefined, + ...config, + }; + case "SAVED_QUERY": + const dataQuery: DragItemQuery = { + ...queryNode, + query: undefined, + dragContext: { width: 0, height: 0 }, + label: "", // TODO: Clarify why there is no label at this point. + tags: [], + type: DNDType.PREVIOUS_QUERY, + id: queryNode.query, + }; + return { + id: queryNode.query, + data: dataQuery, + ...config, + }; + } + }, + [rootConcepts], + ); + + const getQuery = useGetQuery(); + const expandQuery = useCallback( + async (id: string) => { + if (!tree) return; + const queryId = (findNodeById(tree, id)?.data as DragItemQuery).id; + const query = await getQuery(queryId); + + updateTreeNode(id, (node) => { + if (!query.query || query.query.root.type === "EXTERNAL_RESOLVED") + return; + + const expanded = expandNode(query.query.root); + setSelectedNodeId(expanded.id); + + Object.assign(node, expanded); + }); + }, + [getQuery, expandNode, updateTreeNode, tree, setSelectedNodeId], + ); + + const canExpand = useMemo(() => { + return ( + enabled && + selectedNode && + !selectedNode.children && + selectedNode.data?.type !== DNDType.CONCEPT_TREE_NODE && + selectedNode.data?.id + ); + }, [enabled, selectedNode]); + + const onExpand = useCallback(() => { + if (!canExpand) return; + + expandQuery(selectedNode!.id); + }, [selectedNode, expandQuery, canExpand]); + + useHotkeys(hotkey, onExpand, [onExpand]); + + return { + canExpand, + onExpand, + }; +}; diff --git a/frontend/src/js/editor-v2/negation/useNegationEditing.ts b/frontend/src/js/editor-v2/negation/useNegationEditing.ts new file mode 100644 index 0000000000..040673e971 --- /dev/null +++ b/frontend/src/js/editor-v2/negation/useNegationEditing.ts @@ -0,0 +1,30 @@ +import { useCallback } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; + +import { Tree } from "../types"; + +export const useNegationEditing = ({ + enabled, + hotkey, + selectedNode, + updateTreeNode, +}: { + enabled: boolean; + hotkey: string; + selectedNode: Tree | undefined; + updateTreeNode: (id: string, update: (node: Tree) => void) => void; +}) => { + const onNegateClick = useCallback(() => { + if (!selectedNode || !enabled) return; + + updateTreeNode(selectedNode.id, (node) => { + node.negation = !node.negation; + }); + }, [enabled, selectedNode, updateTreeNode]); + + useHotkeys(hotkey, onNegateClick, [onNegateClick]); + + return { + onNegateClick, + }; +}; diff --git a/frontend/src/js/editor-v2/query-node-edit/EditorV2QueryNodeEditor.tsx b/frontend/src/js/editor-v2/query-node-edit/EditorV2QueryNodeEditor.tsx new file mode 100644 index 0000000000..7faecf403f --- /dev/null +++ b/frontend/src/js/editor-v2/query-node-edit/EditorV2QueryNodeEditor.tsx @@ -0,0 +1,217 @@ +import { useCallback } from "react"; + +import { + PostPrefixForSuggestionsParams, + usePostPrefixForSuggestions, +} from "../../api/api"; +import { SelectOptionT } from "../../api/types"; +import { exists } from "../../common/helpers/exists"; +import { mergeFilterOptions } from "../../model/filter"; +import { NodeResetConfig } from "../../model/node"; +import { resetSelects } from "../../model/select"; +import { + resetTables, + tableIsEditable, + tableWithDefaults, +} from "../../model/table"; +import QueryNodeEditor from "../../query-node-editor/QueryNodeEditor"; +import { filterSuggestionToSelectOption } from "../../query-node-editor/suggestionsHelper"; +import { DragItemConceptTreeNode } from "../../standard-query-editor/types"; +import { ModeT } from "../../ui-components/InputRange"; + +export const EditorV2QueryNodeEditor = ({ + node, + onClose, + onChange, +}: { + node: DragItemConceptTreeNode; + onClose: () => void; + onChange: (node: DragItemConceptTreeNode) => void; +}) => { + const showTables = + node.tables.length > 1 && + node.tables.some((table) => tableIsEditable(table)); + + const onUpdateLabel = useCallback( + (label: string) => onChange({ ...node, label }), + [onChange, node], + ); + + const onToggleTable = useCallback( + (tableIdx: number, isExcluded: boolean) => { + const tables = [...node.tables]; + tables[tableIdx] = { ...tables[tableIdx], exclude: isExcluded }; + onChange({ ...node, tables }); + }, + [node, onChange], + ); + + const onDropConcept = useCallback( + (concept: DragItemConceptTreeNode) => { + const ids = [...node.ids, concept.ids[0]]; + onChange({ ...node, ids }); + }, + [node, onChange], + ); + + const onRemoveConcept = useCallback( + (conceptId: string) => { + const ids = node.ids.filter((id) => id !== conceptId); + onChange({ ...node, ids }); + }, + [node, onChange], + ); + + const onSelectSelects = useCallback( + (value: SelectOptionT[]) => { + onChange({ + ...node, + selects: node.selects.map((select) => ({ + ...select, + selected: !!value.find((s) => s.value === select.id), + })), + }); + }, + [node, onChange], + ); + + const onSelectTableSelects = useCallback( + (tableIdx: number, value: SelectOptionT[]) => { + const tables = [...node.tables]; + tables[tableIdx] = { + ...tables[tableIdx], + selects: tables[tableIdx].selects.map((select) => ({ + ...select, + selected: !!value.find((s) => s.value === select.id), + })), + }; + onChange({ ...node, tables }); + }, + [node, onChange], + ); + + const setFilterProperties = useCallback( + (tableIdx: number, filterIdx: number, properties: any) => { + const tables = [...node.tables]; + tables[tableIdx] = { + ...tables[tableIdx], + filters: tables[tableIdx].filters.map((filter, idx) => + idx === filterIdx ? { ...filter, ...properties } : filter, + ), + }; + onChange({ ...node, tables }); + }, + [node, onChange], + ); + + const onSetFilterValue = useCallback( + (tableIdx: number, filterIdx: number, value: any) => { + setFilterProperties(tableIdx, filterIdx, { value }); + }, + [setFilterProperties], + ); + + const onSwitchFilterMode = useCallback( + (tableIdx: number, filterIdx: number, mode: ModeT) => { + const tables = [...node.tables]; + tables[tableIdx] = { + ...tables[tableIdx], + filters: tables[tableIdx].filters.map((filter, idx) => + idx === filterIdx ? { ...filter, mode } : filter, + ), + }; + onChange({ ...node, tables }); + }, + [node, onChange], + ); + + const postPrefixForSuggestions = usePostPrefixForSuggestions(); + const onLoadFilterSuggestions = useCallback( + async ( + params: PostPrefixForSuggestionsParams, + tableIdx: number, + filterIdx: number, + config?: { returnOnly?: boolean }, + ) => { + const suggestions = await postPrefixForSuggestions(params); + + if (!config?.returnOnly) { + const newOptions: SelectOptionT[] = suggestions.values.map( + filterSuggestionToSelectOption, + ); + + const filter = node.tables[tableIdx].filters[filterIdx]; + const options = + params.page === 0 + ? newOptions + : mergeFilterOptions(filter, newOptions); + + if (exists(options)) { + const props = { options, total: suggestions.total }; + + setFilterProperties(tableIdx, filterIdx, props); + } + } + + return suggestions; + }, + [postPrefixForSuggestions, node, setFilterProperties], + ); + + const onResetTable = useCallback( + (tableIdx: number, config: NodeResetConfig) => { + const table = node.tables[tableIdx]; + const resetTable = tableWithDefaults(config)(table); + + const tables = [...node.tables]; + tables[tableIdx] = resetTable; + onChange({ ...node, tables }); + }, + [node, onChange], + ); + + const onResetAllSettings = useCallback( + (config: NodeResetConfig) => { + const tables = resetTables(node.tables, config); + const selects = resetSelects(node.selects, config); + onChange({ ...node, tables, selects }); + }, + [node, onChange], + ); + + const onSetDateColumn = useCallback( + (tableIdx: number, value: string) => { + const tables = [...node.tables]; + tables[tableIdx] = { + ...tables[tableIdx], + dateColumn: { + ...tables[tableIdx].dateColumn!, + value, + }, + }; + onChange({ ...node, tables }); + }, + [node, onChange], + ); + + return ( + + ); +}; diff --git a/frontend/src/js/editor-v2/query-node-edit/useQueryNodeEditing.ts b/frontend/src/js/editor-v2/query-node-edit/useQueryNodeEditing.ts new file mode 100644 index 0000000000..a481c05c69 --- /dev/null +++ b/frontend/src/js/editor-v2/query-node-edit/useQueryNodeEditing.ts @@ -0,0 +1,34 @@ +import { useCallback, useState } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; + +import { Tree } from "../types"; + +export const useQueryNodeEditing = ({ + enabled, + hotkey, + selectedNode, +}: { + enabled: boolean; + hotkey: string; + selectedNode: Tree | undefined; +}) => { + const [showModal, setShowModal] = useState(false); + + const onClose = useCallback(() => setShowModal(false), []); + const onOpen = useCallback(() => { + if (!enabled) return; + if (!selectedNode) return; + + setShowModal(true); + }, [enabled, selectedNode]); + + useHotkeys(hotkey, onOpen, [onOpen], { + preventDefault: true, + }); + + return { + showModal, + onClose, + onOpen, + }; +}; diff --git a/frontend/src/js/editor-v2/time-connection/TimeConnection.tsx b/frontend/src/js/editor-v2/time-connection/TimeConnection.tsx new file mode 100644 index 0000000000..c57748319c --- /dev/null +++ b/frontend/src/js/editor-v2/time-connection/TimeConnection.tsx @@ -0,0 +1,82 @@ +import styled from "@emotion/styled"; +import { DOMAttributes, memo } from "react"; +import { useTranslation } from "react-i18next"; + +import { TreeChildrenTime } from "../types"; +import { + useGetNodeLabel, + useGetTranslatedTimestamp, + useTranslatedInterval, + useTranslatedOperator, +} from "../util"; + +const Container = styled("div")` + margin: 0 auto; + display: inline-flex; + flex-direction: column; + user-select: none; +`; + +const Row = styled("div")` + display: flex; + align-items: center; + gap: 5px; + font-size: ${({ theme }) => theme.font.sm}; +`; + +const ConceptName = styled("span")` + font-weight: bold; + color: ${({ theme }) => theme.col.blueGrayDark}; +`; +const Timestamp = styled("span")` + font-weight: bold; + color: ${({ theme }) => theme.col.palette[0]}; +`; +const Interval = styled("span")` + font-weight: bold; + color: ${({ theme }) => theme.col.palette[1]}; +`; +const Operator = styled("span")` + font-weight: bold; + color: ${({ theme }) => theme.col.palette[2]}; +`; + +export const TimeConnection = memo( + ({ + conditions, + onDoubleClick, + }: { + conditions: TreeChildrenTime; + onDoubleClick: DOMAttributes["onDoubleClick"]; + }) => { + const { t } = useTranslation(); + const getNodeLabel = useGetNodeLabel(); + const getTranslatedTimestamp = useGetTranslatedTimestamp(); + + const aTimestamp = getTranslatedTimestamp(conditions.timestamps[0]); + const bTimestamp = getTranslatedTimestamp(conditions.timestamps[1]); + const a = getNodeLabel(conditions.items[0]); + const b = getNodeLabel(conditions.items[1]); + const operator = useTranslatedOperator(conditions.operator); + const interval = useTranslatedInterval(conditions.interval); + + return ( + + + {aTimestamp} + {t("editorV2.dateRangeFrom")} + {a} + + + {conditions.operator !== "while" && {interval}} + {operator} + + + {bTimestamp} + {t("editorV2.dateRangeFrom")} + {b} + + + ); + }, +); diff --git a/frontend/src/js/editor-v2/time-connection/TimeConnectionModal.tsx b/frontend/src/js/editor-v2/time-connection/TimeConnectionModal.tsx new file mode 100644 index 0000000000..65cf034484 --- /dev/null +++ b/frontend/src/js/editor-v2/time-connection/TimeConnectionModal.tsx @@ -0,0 +1,210 @@ +import styled from "@emotion/styled"; +import { memo, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { exists } from "../../common/helpers/exists"; +import Modal from "../../modal/Modal"; +import BaseInput from "../../ui-components/BaseInput"; +import InputSelect from "../../ui-components/InputSelect/InputSelect"; +import { TimeOperator, TimeTimestamp, TreeChildrenTime } from "../types"; +import { useGetNodeLabel } from "../util"; + +const Content = styled("div")` + display: flex; + flex-direction: column; + gap: 15px; + min-width: 350px; +`; + +const Row = styled("div")` + display: flex; + align-items: center; + gap: 15px; +`; + +const SxBaseInput = styled(BaseInput)` + width: 100px; +`; + +const SxInputSelect = styled(InputSelect)<{ disabled?: boolean }>` + min-width: 150px; + flex-basis: 0; + opacity: ${({ disabled }) => (disabled ? 0.5 : 1)}; +`; + +const DateRangeFrom = styled("span")` + white-space: nowrap; +`; + +const ConceptName = styled("span")` + white-space: nowrap; + font-weight: bold; + color: ${({ theme }) => theme.col.blueGrayDark}; + flex-grow: 1; +`; + +export const TimeConnectionModal = memo( + ({ + conditions, + onChange, + onClose, + }: { + conditions: TreeChildrenTime; + onChange: (conditions: TreeChildrenTime) => void; + onClose: () => void; + }) => { + const conditionsRef = useRef(conditions); + conditionsRef.current = conditions; + + const onChangeRef = useRef(onChange); + onChangeRef.current = onChange; + + const { t } = useTranslation(); + const TIMESTAMP_OPTIONS = useMemo( + () => [ + { value: "some", label: t("editorV2.some") }, + { value: "latest", label: t("editorV2.latest") }, + { value: "earliest", label: t("editorV2.earliest") }, + { value: "every", label: t("editorV2.every") }, + ], + [t], + ); + const OPERATOR_OPTIONS = useMemo( + () => [ + { value: "before", label: t("editorV2.before") }, + { value: "after", label: t("editorV2.after") }, + { value: "while", label: t("editorV2.while") }, + ], + [t], + ); + + const INTERVAL_OPTIONS = useMemo( + () => [ + { value: "some", label: t("editorV2.intervalSome") }, + { value: "dayInterval", label: t("editorV2.dayInterval") }, + ], + [t], + ); + + const [aTimestamp, setATimestamp] = useState(conditions.timestamps[0]); + const [bTimestamp, setBTimestamp] = useState(conditions.timestamps[1]); + const [operator, setOperator] = useState(conditions.operator); + const [interval, setTheInterval] = useState(conditions.interval); + + const getNodeLabel = useGetNodeLabel(); + const a = getNodeLabel(conditions.items[0]); + const b = getNodeLabel(conditions.items[1]); + + useEffect(() => { + onChangeRef.current({ + ...conditionsRef.current, + timestamps: [aTimestamp, bTimestamp], + }); + }, [aTimestamp, bTimestamp]); + + useEffect(() => { + onChangeRef.current({ + ...conditionsRef.current, + operator, + }); + }, [operator]); + + useEffect(() => { + onChangeRef.current({ + ...conditionsRef.current, + interval, + }); + }, [interval]); + + return ( + + + + o.value === aTimestamp)!} + onChange={(opt) => { + if (opt) { + setATimestamp(opt.value as TimeTimestamp); + } + }} + /> + {t("editorV2.dateRangeFrom")} + {a} + + + { + setTheInterval({ + min: val as number, + max: interval ? interval.max : null, + }); + }} + /> + + { + setTheInterval({ + max: val as number | null, + min: interval ? interval.min : null, + }); + }} + /> + { + if (opt?.value === "some") { + setTheInterval(undefined); + } else { + setTheInterval({ min: 1, max: null }); + } + }} + /> + o.value === operator)!} + onChange={(opt) => { + if (opt) { + setOperator(opt.value as TimeOperator); + if (opt.value === "while") { + // Timeout to avoid race condition on effect update above + setTimeout(() => setTheInterval(undefined), 10); + } + } + }} + /> + + + o.value === bTimestamp)!} + onChange={(opt) => { + if (opt) { + setBTimestamp(opt.value as TimeTimestamp); + } + }} + /> + {t("editorV2.dateRangeFrom")} + {b} + + + + ); + }, +); diff --git a/frontend/src/js/editor-v2/time-connection/useTimeConnectionEditing.ts b/frontend/src/js/editor-v2/time-connection/useTimeConnectionEditing.ts new file mode 100644 index 0000000000..d39e4afd65 --- /dev/null +++ b/frontend/src/js/editor-v2/time-connection/useTimeConnectionEditing.ts @@ -0,0 +1,34 @@ +import { useState, useCallback } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; + +import { Tree } from "../types"; + +export const useTimeConnectionEditing = ({ + enabled, + hotkey, + selectedNode, +}: { + enabled: boolean; + hotkey: string; + selectedNode: Tree | undefined; +}) => { + const [showModal, setShowModal] = useState(false); + + const onClose = useCallback(() => setShowModal(false), []); + const onOpen = useCallback(() => { + if (!enabled) return; + if (!selectedNode) return; + + setShowModal(true); + }, [enabled, selectedNode]); + + useHotkeys(hotkey, onOpen, [onOpen], { + preventDefault: true, + }); + + return { + showModal, + onClose, + onOpen, + }; +}; diff --git a/frontend/src/js/editor-v2/types.ts b/frontend/src/js/editor-v2/types.ts new file mode 100644 index 0000000000..9e1b1fe979 --- /dev/null +++ b/frontend/src/js/editor-v2/types.ts @@ -0,0 +1,49 @@ +import { DateRangeT } from "../api/types"; +import { + DragItemConceptTreeNode, + DragItemQuery, +} from "../standard-query-editor/types"; + +export type ConnectionKind = "and" | "or" | "time"; +export type DirectionKind = "horizontal" | "vertical"; + +export interface Tree { + id: string; + parentId?: string; + negation?: boolean; + dates?: { + restriction?: DateRangeT; + excluded?: boolean; + }; + data?: DragItemQuery | DragItemConceptTreeNode; + children?: TreeChildren; +} + +export interface TreeChildrenBase { + direction: DirectionKind; + items: Tree[]; +} + +export interface TreeChildrenAnd extends TreeChildrenBase { + connection: "and"; +} +export interface TreeChildrenOr extends TreeChildrenBase { + connection: "or"; +} + +export type TimeTimestamp = "every" | "some" | "earliest" | "latest"; +export type TimeOperator = "before" | "after" | "while"; +export interface TreeChildrenTime extends TreeChildrenBase { + connection: "time"; + operator: TimeOperator; + timestamps: TimeTimestamp[]; // items.length + interval?: { + min: number | null; + max: number | null; + }; +} +export type TreeChildren = TreeChildrenAnd | TreeChildrenOr | TreeChildrenTime; + +export interface EditorV2Query { + tree?: Tree; +} diff --git a/frontend/src/js/editor-v2/util.ts b/frontend/src/js/editor-v2/util.ts new file mode 100644 index 0000000000..df43e0ecdc --- /dev/null +++ b/frontend/src/js/editor-v2/util.ts @@ -0,0 +1,117 @@ +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; + +import { ConnectionKind, Tree, TreeChildrenTime } from "./types"; + +export const findNodeById = (tree: Tree, id: string): Tree | undefined => { + if (tree.id === id) { + return tree; + } + if (tree.children) { + for (const child of tree.children.items) { + const found = findNodeById(child, id); + if (found) { + return found; + } + } + } + return undefined; +}; + +const getNodeLabel = ( + node: Tree, + getTranslatedConnection: ReturnType, +): string => { + if (node.data?.label) { + return node.data.label; + } else if (node.children) { + return node.children.items + .map((n) => getNodeLabel(n, getTranslatedConnection)) + .join(" " + getTranslatedConnection(node.children.connection) + " "); + } else { + return ""; + } +}; + +export const useGetNodeLabel = (): ((node: Tree) => string) => { + const getTranslatedConnection = useGetTranslatedConnection(); + + return useCallback( + (node: Tree) => getNodeLabel(node, getTranslatedConnection), + [getTranslatedConnection], + ); +}; + +export const useGetTranslatedConnection = () => { + const { t } = useTranslation(); + + return useCallback( + (connection: ConnectionKind | undefined) => { + if (connection === "and") { + return t("editorV2.and"); + } else if (connection === "or") { + return t("editorV2.or"); + } else if (connection === "time") { + return t("editorV2.time"); + } else { + return ""; + } + }, + [t], + ); +}; + +export const useGetTranslatedTimestamp = () => { + const { t } = useTranslation(); + + return useCallback( + (timestamp: "every" | "some" | "earliest" | "latest") => { + if (timestamp === "every") { + return t("editorV2.every"); + } else if (timestamp === "some") { + return t("editorV2.some"); + } else if (timestamp === "earliest") { + return t("editorV2.earliest"); + } else if (timestamp === "latest") { + return t("editorV2.latest"); + } else { + return ""; + } + }, + [t], + ); +}; + +export const useTranslatedOperator = ( + operator: "before" | "after" | "while", +) => { + const { t } = useTranslation(); + + if (operator === "before") { + return t("editorV2.before"); + } else if (operator === "after") { + return t("editorV2.after"); + } else if (operator === "while") { + return t("editorV2.while"); + } +}; + +export const useTranslatedInterval = ( + interval: TreeChildrenTime["interval"], +) => { + const { t } = useTranslation(); + + if (!interval) return t("editorV2.intervalSome"); + + const { min, max } = interval; + + if (min === null && max === null) return t("editorV2.intervalSome"); + if (min !== null && max === null) + return t("editorV2.intervalMinDays", { days: min }); + if (min === null && max !== null) + return t("editorV2.intervalMaxDays", { days: max }); + if (min !== null && max !== null) + return t("editorV2.intervalMinMaxDays", { minDays: min, maxDays: max }); + + return t("editorV2.intervalSome"); +}; diff --git a/frontend/src/js/entity-history/ConceptBubble.ts b/frontend/src/js/entity-history/ConceptBubble.ts new file mode 100644 index 0000000000..9c1ff3539a --- /dev/null +++ b/frontend/src/js/entity-history/ConceptBubble.ts @@ -0,0 +1,10 @@ +import styled from "@emotion/styled"; + +export const ConceptBubble = styled("span")` + padding: 0 3px; + border-radius: ${({ theme }) => theme.borderRadius}; + color: ${({ theme }) => theme.col.black}; + border: 1px solid ${({ theme }) => theme.col.gray}; + background-color: white; + font-size: ${({ theme }) => theme.font.sm}; +`; diff --git a/frontend/src/js/entity-history/EntityCard.tsx b/frontend/src/js/entity-history/EntityCard.tsx index d91f481be7..b9d4f86bab 100644 --- a/frontend/src/js/entity-history/EntityCard.tsx +++ b/frontend/src/js/entity-history/EntityCard.tsx @@ -1,24 +1,19 @@ import styled from "@emotion/styled"; -import { Fragment } from "react"; -import { NumericFormat } from "react-number-format"; -import { useSelector } from "react-redux"; -import { CurrencyConfigT, EntityInfo, TimeStratifiedInfo } from "../api/types"; -import { StateT } from "../app/reducers"; -import { exists } from "../common/helpers/exists"; +import { EntityInfo, TimeStratifiedInfo } from "../api/types"; import EntityInfos from "./EntityInfos"; -import { TimeStratifiedChart } from "./TimeStratifiedChart"; -import { getColumnType } from "./timeline/util"; +import { TabbableTimeStratifiedInfos } from "./TabbableTimeStratifiedInfos"; const Container = styled("div")` display: grid; grid-template-columns: 1fr 1fr; gap: 10px; - padding: 20px; + padding: 20px 24px; background-color: ${({ theme }) => theme.col.bg}; border-radius: ${({ theme }) => theme.borderRadius}; border: 1px solid ${({ theme }) => theme.col.grayLight}; + align-items: center; `; const Centered = styled("div")` @@ -28,86 +23,13 @@ const Centered = styled("div")` gap: 10px; `; -const Grid = styled("div")` - display: grid; - gap: 0 20px; - grid-template-columns: auto auto; -`; - -const Label = styled("div")` - font-size: ${({ theme }) => theme.font.xs}; -`; - -const Value = styled("div")` - font-size: ${({ theme }) => theme.font.sm}; - font-weight: 400; - justify-self: end; -`; - -// @ts-ignore EVALUATE IF WE WANT TO SHOW THIS TABLE WITH FUTURE DATA -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const Table = ({ - timeStratifiedInfos, -}: { - timeStratifiedInfos: TimeStratifiedInfo[]; -}) => { - const currencyConfig = useSelector( - (state) => state.startup.config.currency, - ); - return ( - <> - {timeStratifiedInfos.map((timeStratifiedInfo) => { - return ( - - {timeStratifiedInfo.columns.map((column) => { - const columnType = getColumnType( - timeStratifiedInfo, - column.label, - ); - - const label = column.label; - const value = timeStratifiedInfo.totals[column.label]; - - if (!exists(value)) return <>; - - const valueFormatted = - typeof value === "number" - ? Math.round(value) - : value instanceof Array - ? value.join(", ") - : value; - - return ( - - - - {columnType === "MONEY" && typeof value === "number" ? ( - - ) : ( - valueFormatted - )} - - - ); - })} - - ); - })} - - ); -}; - export const EntityCard = ({ + blurred, className, infos, timeStratifiedInfos, }: { + blurred?: boolean; className?: string; infos: EntityInfo[]; timeStratifiedInfos: TimeStratifiedInfo[]; @@ -115,13 +37,11 @@ export const EntityCard = ({ return ( - - {/* TODO: EVALUATE IF WE WANT TO SHOW THIS TABLE WITH FUTURE DATA - */} + - + {timeStratifiedInfos.length > 0 && ( + + )} ); }; diff --git a/frontend/src/js/entity-history/EntityHeader.tsx b/frontend/src/js/entity-history/EntityHeader.tsx index d3caa665cf..2012de8306 100644 --- a/frontend/src/js/entity-history/EntityHeader.tsx +++ b/frontend/src/js/entity-history/EntityHeader.tsx @@ -29,9 +29,10 @@ const Buttons = styled("div")` gap: 5px; `; -const SxHeading3 = styled(Heading3)` +const SxHeading3 = styled(Heading3)<{ blurred?: boolean }>` flex-shrink: 0; margin: 0; + ${({ blurred }) => blurred && "filter: blur(6px);"} `; const Subtitle = styled("div")` font-size: ${({ theme }) => theme.font.xs}; @@ -47,23 +48,23 @@ const Avatar = styled(SxHeading3)` font-weight: 300; `; -interface Props { - className?: string; - currentEntityIndex: number; - currentEntityId: EntityId; - status: SelectOptionT[]; - setStatus: (value: SelectOptionT[]) => void; - entityStatusOptions: SelectOptionT[]; -} - export const EntityHeader = ({ + blurred, className, currentEntityIndex, currentEntityId, status, setStatus, entityStatusOptions, -}: Props) => { +}: { + blurred?: boolean; + className?: string; + currentEntityIndex: number; + currentEntityId: EntityId; + status: SelectOptionT[]; + setStatus: (value: SelectOptionT[]) => void; + entityStatusOptions: SelectOptionT[]; +}) => { const totalEvents = useSelector( (state) => state.entityHistory.currentEntityData.length, ); @@ -87,7 +88,7 @@ export const EntityHeader = ({
#{currentEntityIndex + 1} - {currentEntityId.id} + {currentEntityId.id} {totalEvents} {t("history.events", { count: totalEvents })} diff --git a/frontend/src/js/entity-history/EntityIdsList.tsx b/frontend/src/js/entity-history/EntityIdsList.tsx index 1d7da5ae22..b9565224c1 100644 --- a/frontend/src/js/entity-history/EntityIdsList.tsx +++ b/frontend/src/js/entity-history/EntityIdsList.tsx @@ -1,7 +1,10 @@ import styled from "@emotion/styled"; +import { faSpinner } from "@fortawesome/free-solid-svg-icons"; import { useMemo } from "react"; import ReactList from "react-list"; +import FaIcon from "../icon/FaIcon"; + import type { EntityIdsStatus } from "./History"; import { useUpdateHistorySession } from "./actions"; import { EntityId } from "./reducer"; @@ -54,19 +57,31 @@ const Gray = styled("span")` color: ${({ theme }) => theme.col.gray}; `; -interface Props { - currentEntityId: EntityId | null; - entityIds: EntityId[]; - updateHistorySession: ReturnType; - entityIdsStatus: EntityIdsStatus; -} +const SxFaIcon = styled(FaIcon)` + margin: 3px 6px; +`; + +const Blurred = styled("span")<{ blurred?: boolean }>` + ${({ blurred }) => blurred && "filter: blur(6px);"} +`; export const EntityIdsList = ({ + blurred, currentEntityId, entityIds, entityIdsStatus, updateHistorySession, -}: Props) => { + loadingId, +}: { + blurred?: boolean; + currentEntityId: EntityId | null; + entityIds: EntityId[]; + updateHistorySession: ReturnType< + typeof useUpdateHistorySession + >["updateHistorySession"]; + entityIdsStatus: EntityIdsStatus; + loadingId?: string; +}) => { const numberWidth = useMemo(() => { const magnitude = Math.ceil(Math.log(entityIds.length) / Math.log(10)); @@ -85,8 +100,10 @@ export const EntityIdsList = ({ > #{index + 1} - {entityId.id} ({entityId.kind}) + {entityId.id}{" "} + ({entityId.kind}) + {loadingId === entityId.id && } {entityIdsStatus[entityId.id] && entityIdsStatus[entityId.id].map((val) => ( diff --git a/frontend/src/js/entity-history/EntityInfos.tsx b/frontend/src/js/entity-history/EntityInfos.tsx index 7c8786b704..8b7e4a12b3 100644 --- a/frontend/src/js/entity-history/EntityInfos.tsx +++ b/frontend/src/js/entity-history/EntityInfos.tsx @@ -10,20 +10,27 @@ const Grid = styled("div")` place-items: center start; `; const Label = styled("div")` - font-size: ${({ theme }) => theme.font.xs}; + font-size: ${({ theme }) => theme.font.sm}; `; -const Value = styled("div")` +const Value = styled("div")<{ blurred?: boolean }>` font-size: ${({ theme }) => theme.font.sm}; font-weight: 400; + ${({ blurred }) => blurred && "filter: blur(6px);"} `; -const EntityInfos = ({ infos }: { infos: EntityInfo[] }) => { +const EntityInfos = ({ + infos, + blurred, +}: { + infos: EntityInfo[]; + blurred?: boolean; +}) => { return ( {infos.map((info) => ( - {info.value} + {info.value} ))} diff --git a/frontend/src/js/entity-history/History.tsx b/frontend/src/js/entity-history/History.tsx index 983b753deb..01c15046b5 100644 --- a/frontend/src/js/entity-history/History.tsx +++ b/frontend/src/js/entity-history/History.tsx @@ -25,6 +25,7 @@ import type { LoadingPayload } from "./LoadHistoryDropzone"; import { Navigation } from "./Navigation"; import SourcesControl from "./SourcesControl"; import Timeline from "./Timeline"; +import VisibilityControl from "./VisibilityControl"; import { useUpdateHistorySession } from "./actions"; import { EntityId } from "./reducer"; @@ -120,6 +121,10 @@ export const History = () => { (state) => state.entityHistory.resultUrls, ); + const [blurred, setBlurred] = useState(false); + const toggleBlurred = useCallback(() => setBlurred((v) => !v), []); + useHotkeys("p", toggleBlurred, [toggleBlurred]); + const [showAdvancedControls, setShowAdvancedControls] = useState(false); useHotkeys("shift+alt+h", () => { @@ -127,7 +132,7 @@ export const History = () => { }); const [detailLevel, setDetailLevel] = useState("summary"); - const updateHistorySession = useUpdateHistorySession(); + const { updateHistorySession } = useUpdateHistorySession(); const { options, sourcesSet, sourcesFilter, setSourcesFilter } = useSourcesControl(); @@ -185,6 +190,7 @@ export const History = () => { defaultSize="400px" > { onLoadFromFile={onLoadFromFile} onResetHistory={onResetEntityStatus} /> - + }>
@@ -206,6 +212,7 @@ export const History = () => { {currentEntityId && ( {
+ {showAdvancedControls && ( { { const { t } = useTranslation(); const dispatch = useDispatch(); - const updateHistorySession = useUpdateHistorySession(); + const { loadingId, updateHistorySession } = useUpdateHistorySession(); const onCloseHistory = useCallback(() => { dispatch(closeHistory()); }, [dispatch]); @@ -200,10 +202,12 @@ export const Navigation = memo( )} {!empty && ( diff --git a/frontend/src/js/entity-history/TabbableTimeStratifiedInfos.tsx b/frontend/src/js/entity-history/TabbableTimeStratifiedInfos.tsx new file mode 100644 index 0000000000..90cbcb4798 --- /dev/null +++ b/frontend/src/js/entity-history/TabbableTimeStratifiedInfos.tsx @@ -0,0 +1,69 @@ +import styled from "@emotion/styled"; +import { useState, useMemo } from "react"; + +import { TimeStratifiedInfo } from "../api/types"; +import SmallTabNavigation from "../small-tab-navigation/SmallTabNavigation"; + +import { TimeStratifiedChart } from "./TimeStratifiedChart"; +import { TimeStratifiedConceptChart } from "./TimeStratifiedConceptChart"; +import { isConceptColumn, isMoneyColumn } from "./timeline/util"; + +const Container = styled("div")` + display: flex; + flex-direction: column; + align-items: flex-end; +`; + +export const TabbableTimeStratifiedInfos = ({ + infos, +}: { + infos: TimeStratifiedInfo[]; +}) => { + const [activeTab, setActiveTab] = useState(infos[0].label); + const options = useMemo(() => { + return infos.map((info) => ({ + value: info.label, + label: () => info.label, + })); + }, [infos]); + + const { data, type } = useMemo(() => { + let infoType = "money"; + let infoData = infos.find((info) => info.label === activeTab); + + if (infoData?.columns.some((c) => isMoneyColumn(c))) { + const columns = infoData?.columns.filter(isMoneyColumn); + + infoData = { + ...infoData, + totals: Object.fromEntries( + Object.entries(infoData?.totals).filter(([k]) => + columns?.map((c) => c.label).includes(k), + ), + ), + columns: columns ?? [], + }; + } else if (infoData?.columns.some(isConceptColumn)) { + // TODO: Handle concept data + infoType = "concept"; + } + + return { data: infoData, type: infoType }; + }, [infos, activeTab]); + + return ( + + + {data && type === "money" && ( + + )} + {data && type === "concept" && ( + + )} + + ); +}; diff --git a/frontend/src/js/entity-history/TimeStratifiedChart.tsx b/frontend/src/js/entity-history/TimeStratifiedChart.tsx index 8bfdb576d9..718d4d1c4c 100644 --- a/frontend/src/js/entity-history/TimeStratifiedChart.tsx +++ b/frontend/src/js/entity-history/TimeStratifiedChart.tsx @@ -8,18 +8,20 @@ import { Tooltip, ChartOptions, } from "chart.js"; -import { useCallback, useMemo } from "react"; +import { useMemo } from "react"; import { Bar } from "react-chartjs-2"; -import { useSelector } from "react-redux"; -import { CurrencyConfigT, TimeStratifiedInfo } from "../api/types"; -import { StateT } from "../app/reducers"; +import { TimeStratifiedInfo } from "../api/types"; import { exists } from "../common/helpers/exists"; +import { formatCurrency } from "./timeline/util"; + +const TRUNCATE_X_AXIS_LABELS_LEN = 18; + ChartJS.register(CategoryScale, LinearScale, BarElement, Tooltip); const ChartContainer = styled("div")` - height: 185px; + height: 190px; width: 100%; display: flex; justify-content: flex-end; @@ -42,39 +44,16 @@ function interpolateDecreasingOpacity(index: number) { return Math.min(1, 1 / (index + 0.3)); } -const useFormatCurrency = () => { - const currencyConfig = useSelector( - (state) => state.startup.config.currency, - ); - - const formatCurrency = useCallback( - (value: number) => { - return value.toLocaleString("de-DE", { - style: "currency", - currency: "EUR", - minimumFractionDigits: currencyConfig.decimalScale, - maximumFractionDigits: currencyConfig.decimalScale, - }); - }, - [currencyConfig], - ); - - return { - formatCurrency, - }; -}; - export const TimeStratifiedChart = ({ - timeStratifiedInfos, + timeStratifiedInfo, }: { - timeStratifiedInfos: TimeStratifiedInfo[]; + timeStratifiedInfo: TimeStratifiedInfo; }) => { const theme = useTheme(); - const infosToVisualize = timeStratifiedInfos[0]; - const labels = infosToVisualize.columns.map((col) => col.label); + const labels = timeStratifiedInfo.columns.map((col) => col.label); const datasets = useMemo(() => { - const sortedYears = [...infosToVisualize.years].sort( + const sortedYears = [...timeStratifiedInfo.years].sort( (a, b) => b.year - a.year, ); @@ -87,21 +66,19 @@ export const TimeStratifiedChart = ({ )}, ${interpolateDecreasingOpacity(i)})`, }; }); - }, [theme, infosToVisualize, labels]); + }, [theme, timeStratifiedInfo, labels]); const data = { labels, datasets, }; - const { formatCurrency } = useFormatCurrency(); - const options: ChartOptions<"bar"> = useMemo(() => { return { plugins: { title: { display: true, - text: infosToVisualize.label, + text: timeStratifiedInfo.label, }, tooltip: { usePointStyle: true, @@ -145,8 +122,9 @@ export const TimeStratifiedChart = ({ x: { ticks: { callback: (idx: any) => { - return labels[idx].length > 12 - ? labels[idx].substring(0, 9) + "..." + return labels[idx].length > TRUNCATE_X_AXIS_LABELS_LEN + ? labels[idx].substring(0, TRUNCATE_X_AXIS_LABELS_LEN - 3) + + "..." : labels[idx]; }, }, @@ -159,7 +137,7 @@ export const TimeStratifiedChart = ({ }, }, }; - }, [infosToVisualize, labels, formatCurrency]); + }, [timeStratifiedInfo, labels]); return ( diff --git a/frontend/src/js/entity-history/TimeStratifiedConceptChart.tsx b/frontend/src/js/entity-history/TimeStratifiedConceptChart.tsx new file mode 100644 index 0000000000..8fb7a71cd8 --- /dev/null +++ b/frontend/src/js/entity-history/TimeStratifiedConceptChart.tsx @@ -0,0 +1,94 @@ +import styled from "@emotion/styled"; + +import { + ColumnDescriptionSemanticConceptColumn, + TimeStratifiedInfo, +} from "../api/types"; +import { getConceptById } from "../concept-trees/globalTreeStoreHelper"; +import WithTooltip from "../tooltip/WithTooltip"; + +import { ConceptBubble } from "./ConceptBubble"; + +const Container = styled("div")` + display: grid; + place-items: center; + gap: 0 3px; + padding: 10px; +`; + +const BubbleYes = styled("div")` + width: 10px; + height: 10px; + border-radius: ${({ theme }) => theme.borderRadius}; + background-color: ${({ theme }) => theme.col.blueGray}; +`; +const BubbleNo = styled("div")` + width: 10px; + height: 10px; + border-radius: ${({ theme }) => theme.borderRadius}; + background-color: ${({ theme }) => theme.col.grayLight}; +`; + +const Year = styled("div")` + font-size: ${({ theme }) => theme.font.sm}; +`; + +export const TimeStratifiedConceptChart = ({ + timeStratifiedInfo, +}: { + timeStratifiedInfo: TimeStratifiedInfo; +}) => { + const conceptColumn = timeStratifiedInfo.columns.at(-1); + + if (!conceptColumn) return null; + + const conceptSemantic = conceptColumn.semantics.find( + (s): s is ColumnDescriptionSemanticConceptColumn => + s.type === "CONCEPT_COLUMN", + ); + + if (!conceptSemantic) return null; + + const years = timeStratifiedInfo.years.map((y) => y.year); + const valuesPerYear = timeStratifiedInfo.years.map((y) => + ((y.values[Object.keys(y.values)[0]] as string[]) || []).map( + (conceptId) => getConceptById(conceptId, conceptSemantic?.concept)!, + ), + ); + + const allValues = [ + ...new Set( + valuesPerYear + .flatMap((v) => v) + .sort((a, b) => { + const nA = Number(a?.label); + const nB = Number(b?.label); + if (!isNaN(nA) && !isNaN(nB)) return nA - nB; + return a?.label.localeCompare(b?.label!); + }), + ), + ]; + + return ( + +
+ {allValues.map((val) => ( + + {val.label} + + ))} + {years.map((y, i) => ( + <> + {y} + {allValues.map((val) => + valuesPerYear[i].includes(val) ? : , + )} + + ))} + + ); +}; diff --git a/frontend/src/js/entity-history/Timeline.tsx b/frontend/src/js/entity-history/Timeline.tsx index c8e9b67501..e8eb61319d 100644 --- a/frontend/src/js/entity-history/Timeline.tsx +++ b/frontend/src/js/entity-history/Timeline.tsx @@ -1,5 +1,5 @@ import styled from "@emotion/styled"; -import { memo, useMemo } from "react"; +import { Fragment, memo, useMemo } from "react"; import { useSelector } from "react-redux"; import { @@ -11,7 +11,6 @@ import { TimeStratifiedInfo, } from "../api/types"; import type { StateT } from "../app/reducers"; -import { useDatasetId } from "../dataset/selectors"; import { ContentFilterValue } from "./ContentControl"; import type { DetailLevel } from "./DetailControl"; @@ -31,29 +30,28 @@ import { const Root = styled("div")` overflow-y: auto; -webkit-overflow-scrolling: touch; - padding: 0 20px 0 10px; + padding: 0 20px 20px 10px; display: inline-grid; - grid-template-columns: 200px auto; - grid-auto-rows: minmax(min-content, max-content); - gap: 12px 4px; + grid-template-columns: 280px auto; + grid-auto-rows: minmax(min-content, max-content) 1fr; + gap: 20px 4px; width: 100%; `; +const Divider = styled("div")` + grid-column: 1 / span 2; + height: 1px; + background: ${({ theme }) => theme.col.grayLight}; +`; + const SxEntityCard = styled(EntityCard)` grid-column: span 2; `; -interface Props { - className?: string; - currentEntityInfos: EntityInfo[]; - currentEntityTimeStratifiedInfos: TimeStratifiedInfo[]; - detailLevel: DetailLevel; - sources: Set; - contentFilter: ContentFilterValue; - getIsOpen: (year: number, quarter?: number) => boolean; - toggleOpenYear: (year: number) => void; - toggleOpenQuarter: (year: number, quarter: number) => void; -} +const SxTimelineEmptyPlaceholder = styled(TimelineEmptyPlaceholder)` + grid-column: span 2; + height: 100%; +`; const Timeline = ({ className, @@ -65,8 +63,19 @@ const Timeline = ({ getIsOpen, toggleOpenYear, toggleOpenQuarter, -}: Props) => { - const datasetId = useDatasetId(); + blurred, +}: { + className?: string; + currentEntityInfos: EntityInfo[]; + currentEntityTimeStratifiedInfos: TimeStratifiedInfo[]; + detailLevel: DetailLevel; + sources: Set; + contentFilter: ContentFilterValue; + getIsOpen: (year: number, quarter?: number) => boolean; + toggleOpenYear: (year: number) => void; + toggleOpenQuarter: (year: number, quarter: number) => void; + blurred?: boolean; +}) => { const data = useSelector( (state) => state.entityHistory.currentEntityData, ); @@ -82,35 +91,32 @@ const Timeline = ({ secondaryIds: columnBuckets.secondaryIds, }); - if (!datasetId) return null; - - if (eventsByQuarterWithGroups.length === 0) { - return ; - } - return ( - {eventsByQuarterWithGroups.map(({ year, quarterwiseData }) => ( - + {eventsByQuarterWithGroups.length === 0 && } + {eventsByQuarterWithGroups.map(({ year, quarterwiseData }, i) => ( + + + {i < eventsByQuarterWithGroups.length - 1 && } + ))} ); diff --git a/frontend/src/js/entity-history/VisibilityControl.tsx b/frontend/src/js/entity-history/VisibilityControl.tsx new file mode 100644 index 0000000000..f1bfed7ce6 --- /dev/null +++ b/frontend/src/js/entity-history/VisibilityControl.tsx @@ -0,0 +1,40 @@ +import styled from "@emotion/styled"; +import { faEye, faEyeSlash } from "@fortawesome/free-regular-svg-icons"; +import { memo } from "react"; +import { useTranslation } from "react-i18next"; + +import IconButton from "../button/IconButton"; +import WithTooltip from "../tooltip/WithTooltip"; + +const Root = styled("div")` + display: flex; + flex-direction: column; + align-items: center; +`; + +const SxIconButton = styled(IconButton)` + padding: 8px 10px; +`; + +const VisibilityControl = ({ + blurred, + toggleBlurred, +}: { + blurred?: boolean; + toggleBlurred: () => void; +}) => { + const { t } = useTranslation(); + + return ( + + + + + + ); +}; + +export default memo(VisibilityControl); diff --git a/frontend/src/js/entity-history/actions.ts b/frontend/src/js/entity-history/actions.ts index fb7b8d18f6..1ccb5c82e5 100644 --- a/frontend/src/js/entity-history/actions.ts +++ b/frontend/src/js/entity-history/actions.ts @@ -1,4 +1,6 @@ -import { useCallback } from "react"; +import startOfYear from "date-fns/startOfYear"; +import subYears from "date-fns/subYears"; +import { useCallback, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useDispatch, useSelector } from "react-redux"; import { ActionType, createAction, createAsyncAction } from "typesafe-actions"; @@ -117,7 +119,7 @@ function getPreferredIdColumns(columns: ColumnDescription[]) { export function useNewHistorySession() { const dispatch = useDispatch(); const loadPreviewData = useLoadPreviewData(); - const updateHistorySession = useUpdateHistorySession(); + const { updateHistorySession } = useUpdateHistorySession(); return async (url: string, columns: ColumnDescription[], label: string) => { dispatch(loadHistoryData.request()); @@ -169,6 +171,8 @@ export function useNewHistorySession() { }; } +const SHOW_LOADING_DELAY = 300; + export function useUpdateHistorySession() { const dispatch = useDispatch(); const datasetId = useDatasetId(); @@ -176,12 +180,21 @@ export function useUpdateHistorySession() { const getAuthorizedUrl = useGetAuthorizedUrl(); const { t } = useTranslation(); + const loadingIdTimeout = useRef(); + const [loadingId, setLoadingId] = useState(); + const defaultEntityHistoryParams = useSelector< StateT, StateT["entityHistory"]["defaultParams"] >((state) => state.entityHistory.defaultParams); + const observationPeriodMin = useSelector((state) => { + return ( + state.startup.config.observationPeriodStart || + formatStdDate(subYears(startOfYear(new Date()), 1)) + ); + }); - return useCallback( + const updateHistorySession = useCallback( async ({ entityId, entityIds, @@ -194,6 +207,13 @@ export function useUpdateHistorySession() { }) => { if (!datasetId) return; + if (loadingIdTimeout.current) { + clearTimeout(loadingIdTimeout.current); + } + loadingIdTimeout.current = setTimeout(() => { + setLoadingId(entityId.id); + }, SHOW_LOADING_DELAY); + try { dispatch(loadHistoryData.request()); @@ -203,7 +223,7 @@ export function useUpdateHistorySession() { entityId, defaultEntityHistoryParams.sources, { - min: defaultEntityHistoryParams.observationPeriodMin, + min: observationPeriodMin, max: formatStdDate(new Date()), }, ); @@ -262,6 +282,11 @@ export function useUpdateHistorySession() { }), ); } + + if (loadingIdTimeout.current) { + clearTimeout(loadingIdTimeout.current); + } + setLoadingId(undefined); }, [ t, @@ -270,8 +295,14 @@ export function useUpdateHistorySession() { dispatch, getAuthorizedUrl, getEntityHistory, + observationPeriodMin, ], ); + + return { + loadingId, + updateHistorySession, + }; } const transformEntityData = (data: { [key: string]: any }[]): EntityEvent[] => { diff --git a/frontend/src/js/entity-history/reducer.ts b/frontend/src/js/entity-history/reducer.ts index 11a577b2ee..f6d59e45d5 100644 --- a/frontend/src/js/entity-history/reducer.ts +++ b/frontend/src/js/entity-history/reducer.ts @@ -35,7 +35,6 @@ export interface EntityId { export type EntityHistoryStateT = { defaultParams: { - observationPeriodMin: string; sources: HistorySources; searchConcept: string | null; searchFilters: string[]; @@ -57,7 +56,6 @@ export type EntityHistoryStateT = { const initialState: EntityHistoryStateT = { defaultParams: { - observationPeriodMin: "2020-01-01", sources: { all: [], default: [] }, searchConcept: null, searchFilters: [], @@ -86,7 +84,6 @@ export default function reducer( return { ...state, defaultParams: { - observationPeriodMin: action.payload.observationPeriodMin, sources: { all: action.payload.all, default: action.payload.default }, searchConcept: action.payload.searchConcept, searchFilters: action.payload.searchFilters || [], diff --git a/frontend/src/js/entity-history/timeline/ConceptName.tsx b/frontend/src/js/entity-history/timeline/ConceptName.tsx index e3769633e3..d49facb336 100644 --- a/frontend/src/js/entity-history/timeline/ConceptName.tsx +++ b/frontend/src/js/entity-history/timeline/ConceptName.tsx @@ -2,7 +2,7 @@ import styled from "@emotion/styled"; import { faFolder } from "@fortawesome/free-solid-svg-icons"; import { memo } from "react"; -import { ConceptIdT, DatasetT } from "../../api/types"; +import { ConceptIdT } from "../../api/types"; import { getConceptById } from "../../concept-trees/globalTreeStoreHelper"; import FaIcon from "../../icon/FaIcon"; @@ -18,24 +18,12 @@ const Named = styled("span")` interface Props { className?: string; title?: string; - datasetId: DatasetT["id"] | null; - conceptId: string; // Because it's just part of an actual ConceptT['id'] + conceptId: string; rootConceptId: ConceptIdT; } -const ConceptName = ({ - className, - title, - datasetId, - rootConceptId, - conceptId, -}: Props) => { - // TODO: refactor. It's very implicit that the id is - // somehow containing the datasetId. - if (!datasetId) return null; - - const fullConceptId = `${datasetId}.${conceptId}`; - const concept = getConceptById(fullConceptId, rootConceptId); +const ConceptName = ({ className, title, rootConceptId, conceptId }: Props) => { + const concept = getConceptById(conceptId, rootConceptId); if (!concept) { return ( @@ -55,7 +43,7 @@ const ConceptName = ({ ); - if (fullConceptId === rootConceptId) { + if (conceptId === rootConceptId) { return (
{conceptName} diff --git a/frontend/src/js/entity-history/timeline/EventCard.tsx b/frontend/src/js/entity-history/timeline/EventCard.tsx index 60e3b85868..11bdb01202 100644 --- a/frontend/src/js/entity-history/timeline/EventCard.tsx +++ b/frontend/src/js/entity-history/timeline/EventCard.tsx @@ -11,7 +11,6 @@ import type { ColumnDescription, ConceptIdT, CurrencyConfigT, - DatasetT, } from "../../api/types"; import { exists } from "../../common/helpers/exists"; import FaIcon from "../../icon/FaIcon"; @@ -93,29 +92,25 @@ const Bullet = styled("div")` flex-shrink: 0; `; -interface Props { - row: EntityEvent; - columns: Record; - columnBuckets: ColumnBuckets; - datasetId: DatasetT["id"]; - contentFilter: ContentFilterValue; - currencyConfig: CurrencyConfigT; - rootConceptIdsByColumn: Record; - groupedRows?: EntityEvent[]; - groupedRowsKeysWithDifferentValues?: string[]; -} - const EventCard = ({ row, columns, columnBuckets, - datasetId, currencyConfig, contentFilter, rootConceptIdsByColumn, groupedRows, groupedRowsKeysWithDifferentValues, -}: Props) => { +}: { + row: EntityEvent; + columns: Record; + columnBuckets: ColumnBuckets; + contentFilter: ContentFilterValue; + currencyConfig: CurrencyConfigT; + rootConceptIdsByColumn: Record; + groupedRows?: EntityEvent[]; + groupedRowsKeysWithDifferentValues?: string[]; +}) => { const { t } = useTranslation(); const applicableGroupableIds = columnBuckets.groupableIds.filter( @@ -168,7 +163,6 @@ const EventCard = ({ )} {groupedRowsKeysWithDifferentValues && groupedRows && ( ; groupedRows: EntityEvent[]; groupedRowsKeysWithDifferentValues: string[]; @@ -64,7 +63,6 @@ interface Props { } const GroupedContent = ({ - datasetId, columns, groupedRows, groupedRowsKeysWithDifferentValues, @@ -114,7 +112,6 @@ const GroupedContent = ({ differencesKeys.map((key) => ( ; }) => { - if (!columnDescription) { + if (isDateColumn(columnDescription)) { return cell.from === cell.to ? ( {formatHistoryDayRange(cell.from)} ) : ( @@ -169,7 +164,6 @@ const Cell = memo( ); diff --git a/frontend/src/js/entity-history/timeline/Quarter.tsx b/frontend/src/js/entity-history/timeline/Quarter.tsx index 1042b68c03..178897552c 100644 --- a/frontend/src/js/entity-history/timeline/Quarter.tsx +++ b/frontend/src/js/entity-history/timeline/Quarter.tsx @@ -7,7 +7,6 @@ import { ColumnDescription, ConceptIdT, CurrencyConfigT, - DatasetT, } from "../../api/types"; import FaIcon from "../../icon/FaIcon"; import { ContentFilterValue } from "../ContentControl"; @@ -55,7 +54,7 @@ const InlineGrid = styled("div")` cursor: pointer; border: 1px solid transparent; border-radius: ${({ theme }) => theme.borderRadius}; - padding: 5px; + padding: 6px 10px; &:hover { border: 1px solid ${({ theme }) => theme.col.blueGray}; } @@ -90,7 +89,6 @@ const Quarter = ({ currencyConfig, rootConceptIdsByColumn, contentFilter, - datasetId, }: { year: number; quarter: number; @@ -100,7 +98,6 @@ const Quarter = ({ detailLevel: DetailLevel; toggleOpenQuarter: (year: number, quarter: number) => void; differences: string[][]; - datasetId: DatasetT["id"]; columns: Record; columnBuckets: ColumnBuckets; contentFilter: ContentFilterValue; @@ -151,7 +148,6 @@ const Quarter = ({ key={`${index}-${evtIdx}`} columns={columns} columnBuckets={columnBuckets} - datasetId={datasetId} contentFilter={contentFilter} rootConceptIdsByColumn={rootConceptIdsByColumn} row={evt} @@ -174,7 +170,6 @@ const Quarter = ({ key={index} columns={columns} columnBuckets={columnBuckets} - datasetId={datasetId} contentFilter={contentFilter} rootConceptIdsByColumn={rootConceptIdsByColumn} row={firstRowWithoutDifferences} diff --git a/frontend/src/js/entity-history/timeline/SmallHeading.tsx b/frontend/src/js/entity-history/timeline/SmallHeading.tsx index e72a52de23..51d5a8a06f 100644 --- a/frontend/src/js/entity-history/timeline/SmallHeading.tsx +++ b/frontend/src/js/entity-history/timeline/SmallHeading.tsx @@ -6,4 +6,5 @@ export const SmallHeading = styled(Heading4)` flex-shrink: 0; margin: 0; color: ${({ theme }) => theme.col.black}; + font-size: ${({ theme }) => theme.font.md}; `; diff --git a/frontend/src/js/entity-history/timeline/TimelineEmptyPlaceholder.tsx b/frontend/src/js/entity-history/timeline/TimelineEmptyPlaceholder.tsx index 15453672d3..1ccd9a8757 100644 --- a/frontend/src/js/entity-history/timeline/TimelineEmptyPlaceholder.tsx +++ b/frontend/src/js/entity-history/timeline/TimelineEmptyPlaceholder.tsx @@ -47,7 +47,11 @@ const Description = styled("p")` margin: 0; `; -export const TimelineEmptyPlaceholder = () => { +export const TimelineEmptyPlaceholder = ({ + className, +}: { + className?: string; +}) => { const { t } = useTranslation(); const ids = useSelector( @@ -58,7 +62,7 @@ export const TimelineEmptyPlaceholder = () => { ); return ( - +
diff --git a/frontend/src/js/entity-history/timeline/Year.tsx b/frontend/src/js/entity-history/timeline/Year.tsx index ccc43d5250..4ed30bbc67 100644 --- a/frontend/src/js/entity-history/timeline/Year.tsx +++ b/frontend/src/js/entity-history/timeline/Year.tsx @@ -5,7 +5,6 @@ import { ColumnDescription, ConceptIdT, CurrencyConfigT, - DatasetT, TimeStratifiedInfo, } from "../../api/types"; import { ContentFilterValue } from "../ContentControl"; @@ -22,7 +21,6 @@ const YearGroup = styled("div")` `; const Year = ({ - datasetId, year, getIsOpen, toggleOpenYear, @@ -36,7 +34,6 @@ const Year = ({ rootConceptIdsByColumn, timeStratifiedInfos, }: { - datasetId: DatasetT["id"]; year: number; getIsOpen: (year: number, quarter?: number) => boolean; toggleOpenYear: (year: number) => void; @@ -78,7 +75,6 @@ const Year = ({ theme.font.xs}; - color: ${({ theme }) => theme.col.gray}; padding: 0 10px 0 0; `; const StickyWrap = styled("div")` position: sticky; top: 0; left: 0; - padding: 5px; + padding: 6px 10px; cursor: pointer; display: grid; grid-template-columns: 16px 1fr; @@ -44,19 +48,24 @@ const Grid = styled("div")` gap: 0px 10px; `; +const ConceptRow = styled("div")` + grid-column: span 2; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; +`; + const Value = styled("div")` - font-size: ${({ theme }) => theme.font.tiny}; + font-size: ${({ theme }) => theme.font.sm}; font-weight: 400; justify-self: end; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; width: 100%; text-align: right; `; const Label = styled("div")` - font-size: ${({ theme }) => theme.font.tiny}; + font-size: ${({ theme }) => theme.font.sm}; max-width: 100%; white-space: nowrap; overflow: hidden; @@ -70,10 +79,6 @@ const TimeStratifiedInfos = ({ year: number; timeStratifiedInfos: TimeStratifiedInfo[]; }) => { - const currencyUnit = useSelector( - (state) => state.startup.config.currency.unit, - ); - const infos = timeStratifiedInfos .map((info) => { return { @@ -102,20 +107,71 @@ const TimeStratifiedInfos = ({ info.columns.findIndex((c) => c.label === l2), ) .map(([label, value]) => { - const columnType = getColumnType(info, label); - const valueFormatted = - typeof value === "number" - ? Math.round(value) - : value instanceof Array - ? value.join(", ") - : value; + const column = info.columns.find((c) => c.label === label); + + if (!column) { + return null; + } + + if (isConceptColumn(column)) { + const semantic = column.semantics.find( + (s): s is ColumnDescriptionSemanticConceptColumn => + s.type === "CONCEPT_COLUMN", + ); + + if (value instanceof Array) { + const concepts = value + .map((v) => getConceptById(v, semantic!.concept)) + .filter(exists) + .sort((c1, c2) => { + const n1 = Number(c1.label); + const n2 = Number(c2.label); + if (!isNaN(n1) && !isNaN(n2)) { + return n1 - n2; + } + return c1.label.localeCompare(c2.label); + }); + + return ( + + + + {concepts.map((concept) => ( + + {concept.label} + + ))} + + + ); + } + + // TOOD: Potentially support single-value concepts + } + + let valueFormatted: string | number | string[] = value; + if (typeof value === "number") { + valueFormatted = isMoneyColumn(column) + ? formatCurrency(value) + : Math.round(value); + } else if (value instanceof Array) { + valueFormatted = value.join(", "); + } return ( - + {valueFormatted} - {columnType === "MONEY" ? " " + currencyUnit : ""} ); diff --git a/frontend/src/js/entity-history/timeline/util.ts b/frontend/src/js/entity-history/timeline/util.ts index eef88d1c25..b3f7db5013 100644 --- a/frontend/src/js/entity-history/timeline/util.ts +++ b/frontend/src/js/entity-history/timeline/util.ts @@ -1,8 +1,11 @@ -import { ColumnDescription, TimeStratifiedInfo } from "../../api/types"; +import { ColumnDescription } from "../../api/types"; export const isIdColumn = (columnDescription: ColumnDescription) => columnDescription.semantics.some((s) => s.type === "ID"); +export const isDateColumn = (columnDescription: ColumnDescription) => + columnDescription.semantics.some((s) => s.type === "EVENT_DATE"); + export const isGroupableColumn = (columnDescription: ColumnDescription) => columnDescription.semantics.some((s) => s.type === "GROUP"); @@ -19,9 +22,12 @@ export const isMoneyColumn = (columnDescription: ColumnDescription) => export const isSecondaryIdColumn = (columnDescription: ColumnDescription) => columnDescription.semantics.some((s) => s.type === "SECONDARY_ID"); -export const getColumnType = ( - timeStratifiedInfo: TimeStratifiedInfo, - label: string, -) => { - return timeStratifiedInfo.columns.find((c) => c.label === label)?.type; -}; +export const formatCurrency = (value: number) => + value.toLocaleString(navigator.language, { + style: "currency", + + currency: "EUR", + unitDisplay: "short", + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }); diff --git a/frontend/src/js/error-fallback/ErrorFallback.tsx b/frontend/src/js/error-fallback/ErrorFallback.tsx index 8e31913064..febcedfd27 100644 --- a/frontend/src/js/error-fallback/ErrorFallback.tsx +++ b/frontend/src/js/error-fallback/ErrorFallback.tsx @@ -29,16 +29,33 @@ const ReloadButton = styled(TransparentButton)` margin-top: 10px; `; -const ErrorFallback = () => { +const ErrorFallback = ({ + allowFullRefresh, + onReset, +}: { + allowFullRefresh?: boolean; + onReset?: () => void; +}) => { const { t } = useTranslation(); return ( {t("error.sorry")} {t("error.description")} - window.location.reload()}> - {t("error.reload")} - + {allowFullRefresh && ( + <> + {t("error.reloadDescription")} + window.location.reload()}> + {t("error.reload")} + + + )} + {onReset && ( + <> + {t("error.resetDescription")} + {t("error.reset")} + + )} ); }; diff --git a/frontend/src/js/error-fallback/ResetableErrorBoundary.tsx b/frontend/src/js/error-fallback/ResetableErrorBoundary.tsx new file mode 100644 index 0000000000..a390b9d593 --- /dev/null +++ b/frontend/src/js/error-fallback/ResetableErrorBoundary.tsx @@ -0,0 +1,25 @@ +import { ReactNode, useCallback, useState } from "react"; +import { ErrorBoundary } from "react-error-boundary"; + +import ErrorFallback from "./ErrorFallback"; + +export const ResetableErrorBoundary = ({ + children, +}: { + children: ReactNode; +}) => { + const [resetKey, setResetKey] = useState(0); + const onReset = useCallback(() => setResetKey((key) => key + 1), []); + + return ( + ( + + )} + > + {children} + + ); +}; diff --git a/frontend/src/js/external-forms/FormsNavigation.tsx b/frontend/src/js/external-forms/FormsNavigation.tsx index 0e9832423b..6da71d6a0a 100644 --- a/frontend/src/js/external-forms/FormsNavigation.tsx +++ b/frontend/src/js/external-forms/FormsNavigation.tsx @@ -1,5 +1,5 @@ import styled from "@emotion/styled"; -import { faTrashAlt } from "@fortawesome/free-regular-svg-icons"; +import { faTrash } from "@fortawesome/free-solid-svg-icons"; import { useTranslation } from "react-i18next"; import { useSelector, useDispatch } from "react-redux"; @@ -89,7 +89,7 @@ const FormsNavigation = ({ onReset }: { onReset: () => void }) => { confirmationText={t("externalForms.common.clearConfirm")} > - + diff --git a/frontend/src/js/external-forms/FormsQueryRunner.tsx b/frontend/src/js/external-forms/FormsQueryRunner.tsx index b79d022a09..0bbcff5a14 100644 --- a/frontend/src/js/external-forms/FormsQueryRunner.tsx +++ b/frontend/src/js/external-forms/FormsQueryRunner.tsx @@ -1,4 +1,3 @@ -import { FC } from "react"; import { useFormContext, useFormState } from "react-hook-form"; import { useSelector } from "react-redux"; @@ -37,7 +36,7 @@ const isButtonEnabled = ({ ); }; -const FormQueryRunner: FC = () => { +const FormQueryRunner = () => { const datasetId = useDatasetId(); const queryRunner = useSelector( selectQueryRunner, @@ -83,7 +82,7 @@ const FormQueryRunner: FC = () => { return ( (center ? "center" : "left")}; font-size: ${({ theme, large, tiny }) => large ? theme.font.md : tiny ? theme.font.tiny : theme.font.sm}; - color: ${({ theme, white, gray, light, main, active, disabled }) => + color: ${({ theme, white, gray, red, light, main, active, disabled }) => disabled ? theme.col.grayMediumLight + : red + ? theme.col.red : gray ? theme.col.gray : active diff --git a/frontend/src/js/model/node.ts b/frontend/src/js/model/node.ts index 92f2dc8012..2f3d01a8fb 100644 --- a/frontend/src/js/model/node.ts +++ b/frontend/src/js/model/node.ts @@ -7,9 +7,11 @@ import { faFolderOpen, faMinus, } from "@fortawesome/free-solid-svg-icons"; +import { useTranslation } from "react-i18next"; import { ConceptElementT, ConceptT } from "../api/types"; import { DNDType } from "../common/constants/dndTypes"; +import { getConceptById } from "../concept-trees/globalTreeStoreHelper"; import type { ConceptQueryNodeType, DragItemConceptTreeNode, @@ -54,8 +56,16 @@ export const nodeHasEmptySettings = (node: StandardQueryNodeT) => { export const nodeHasFilterValues = (node: StandardQueryNodeT) => nodeIsConceptQueryNode(node) && tablesHaveFilterValues(node.tables); +const nodeHasNonDefaultExcludeTimestamps = (node: StandardQueryNodeT) => { + if (!nodeIsConceptQueryNode(node)) return node.excludeTimestamps; + + const root = getConceptById(node.tree, node.tree); + + return node.excludeTimestamps !== root?.excludeFromTimeAggregation; +}; + export const nodeHasNonDefaultSettings = (node: StandardQueryNodeT) => - node.excludeTimestamps || + nodeHasNonDefaultExcludeTimestamps(node) || node.excludeFromSecondaryId || (nodeIsConceptQueryNode(node) && (objectHasNonDefaultSelects(node) || @@ -149,3 +159,28 @@ export const canNodeBeDropped = ( const itemHasConceptRoot = item.tree === node.tree; return itemHasConceptRoot && !itemAlreadyInNode; }; + +export const useActiveState = (node?: StandardQueryNodeT) => { + const { t } = useTranslation(); + + if (!node) { + return { + active: false, + tooltipText: undefined, + }; + } + + const hasNonDefaultSettings = !node.error && nodeHasNonDefaultSettings(node); + const hasFilterValues = nodeHasFilterValues(node); + + const tooltipText = hasNonDefaultSettings + ? t("queryEditor.hasNonDefaultSettings") + : hasFilterValues + ? t("queryEditor.hasDefaultSettings") + : undefined; + + return { + active: hasNonDefaultSettings || hasFilterValues, + tooltipText, + }; +}; diff --git a/frontend/src/js/previous-queries/upload/CSVColumnPicker.tsx b/frontend/src/js/previous-queries/upload/CSVColumnPicker.tsx index a330e6220f..f53f1f6cb9 100644 --- a/frontend/src/js/previous-queries/upload/CSVColumnPicker.tsx +++ b/frontend/src/js/previous-queries/upload/CSVColumnPicker.tsx @@ -1,5 +1,5 @@ import styled from "@emotion/styled"; -import { faTrashAlt } from "@fortawesome/free-regular-svg-icons"; +import { faTrash } from "@fortawesome/free-solid-svg-icons"; import { faCheckCircle, faDownload, @@ -282,7 +282,7 @@ const CSVColumnPicker: FC = ({ {csv.length} Zeilen
- + {csv.length > 0 && ( diff --git a/frontend/src/js/query-node-editor/ConceptEntry.tsx b/frontend/src/js/query-node-editor/ConceptEntry.tsx index 310881ebff..07a41c396b 100644 --- a/frontend/src/js/query-node-editor/ConceptEntry.tsx +++ b/frontend/src/js/query-node-editor/ConceptEntry.tsx @@ -1,5 +1,5 @@ import styled from "@emotion/styled"; -import { faTrashAlt } from "@fortawesome/free-regular-svg-icons"; +import { faTrashCan } from "@fortawesome/free-regular-svg-icons"; import { useTranslation } from "react-i18next"; import type { ConceptIdT, ConceptT } from "../api/types"; @@ -77,7 +77,7 @@ const ConceptEntry = ({ onRemoveConcept(conceptId)} tiny - icon={faTrashAlt} + icon={faTrashCan} /> )} diff --git a/frontend/src/js/query-node-editor/ContentColumn.tsx b/frontend/src/js/query-node-editor/ContentColumn.tsx index 58580cc673..667d813f5b 100644 --- a/frontend/src/js/query-node-editor/ContentColumn.tsx +++ b/frontend/src/js/query-node-editor/ContentColumn.tsx @@ -105,12 +105,14 @@ const ContentColumn: FC = ({ {t("queryNodeEditor.properties")} - + {(onToggleSecondaryIdExclude || onToggleTimestamps) && ( + + )} {nodeIsConceptQueryNode(node) && node.selects && ( setFileChoice({ label: resultUrl.label, ending })} bgHover + showColoredIcon > {truncate(resultUrl.label)} @@ -137,7 +138,7 @@ const DownloadResultsDropdownButton = ({ {!tiny && ( <> - + {truncChosenLabel} diff --git a/frontend/src/js/query-runner/QueryRunner.tsx b/frontend/src/js/query-runner/QueryRunner.tsx index cc621271ce..7b01ef1918 100644 --- a/frontend/src/js/query-runner/QueryRunner.tsx +++ b/frontend/src/js/query-runner/QueryRunner.tsx @@ -40,7 +40,7 @@ const LoadingGroup = styled("div")` interface PropsT { queryRunner?: QueryRunnerStateT; isQueryRunning: boolean; - isButtonEnabled: boolean; + disabled: boolean; buttonTooltip?: string; startQuery: () => void; stopQuery: () => void; @@ -52,7 +52,7 @@ const QueryRunner: FC = ({ stopQuery, buttonTooltip, isQueryRunning, - isButtonEnabled, + disabled, }) => { const btnAction = isQueryRunning ? stopQuery : startQuery; const isStartStopLoading = @@ -64,9 +64,9 @@ const QueryRunner: FC = ({ useHotkeys( "shift+enter", () => { - if (isButtonEnabled) btnAction(); + if (!disabled) btnAction(); }, - [isButtonEnabled, btnAction], + [disabled, btnAction], ); return ( @@ -77,7 +77,7 @@ const QueryRunner: FC = ({ onClick={btnAction} isStartStopLoading={isStartStopLoading} isQueryRunning={isQueryRunning} - disabled={!isButtonEnabled} + disabled={disabled} /> diff --git a/frontend/src/js/query-runner/actions.ts b/frontend/src/js/query-runner/actions.ts index ac51774bb9..1c713af6a1 100644 --- a/frontend/src/js/query-runner/actions.ts +++ b/frontend/src/js/query-runner/actions.ts @@ -23,6 +23,7 @@ import { errorPayload, successPayload, } from "../common/actions/genericActions"; +import { EditorV2Query } from "../editor-v2/types"; import { getExternalSupportedErrorMessage } from "../environment"; import { useLoadFormConfigs, @@ -58,7 +59,11 @@ export type QueryRunnerActions = ActionType< by sending a DELETE request for that query ID */ -export type QueryTypeT = "standard" | "timebased" | "externalForms"; +export type QueryTypeT = + | "standard" + | "editorV2" + | "timebased" + | "externalForms"; export const startQuery = createAsyncAction( "query-runners/START_QUERY_START", @@ -80,6 +85,7 @@ export const useStartQuery = (queryType: QueryTypeT) => { datasetId: DatasetT["id"], query: | StandardQueryStateT + | EditorV2Query | ValidatedTimebasedQueryStateT | FormQueryPostPayload, { @@ -96,7 +102,10 @@ export const useStartQuery = (queryType: QueryTypeT) => { : () => postQueries( datasetId, - query as StandardQueryStateT | ValidatedTimebasedQueryStateT, + query as + | StandardQueryStateT + | EditorV2Query + | ValidatedTimebasedQueryStateT, { queryType, selectedSecondaryId, diff --git a/frontend/src/js/standard-query-editor/QueryClearButton.tsx b/frontend/src/js/standard-query-editor/QueryClearButton.tsx index 97aa4c7575..9cf8ef0ef2 100644 --- a/frontend/src/js/standard-query-editor/QueryClearButton.tsx +++ b/frontend/src/js/standard-query-editor/QueryClearButton.tsx @@ -1,4 +1,4 @@ -import { faTrashAlt } from "@fortawesome/free-regular-svg-icons"; +import { faTrash } from "@fortawesome/free-solid-svg-icons"; import { FC } from "react"; import { useTranslation } from "react-i18next"; import { useDispatch } from "react-redux"; @@ -25,7 +25,7 @@ const QueryClearButton: FC = ({ className }) => { onConfirm={onClearQuery} > - +
diff --git a/frontend/src/js/standard-query-editor/QueryNode.tsx b/frontend/src/js/standard-query-editor/QueryNode.tsx index 73951a4ba5..358c9f3e46 100644 --- a/frontend/src/js/standard-query-editor/QueryNode.tsx +++ b/frontend/src/js/standard-query-editor/QueryNode.tsx @@ -1,7 +1,6 @@ import styled from "@emotion/styled"; import { memo, useCallback, useRef } from "react"; import { useDrag } from "react-dnd"; -import { useTranslation } from "react-i18next"; import { useSelector } from "react-redux"; import type { QueryT } from "../api/types"; @@ -9,10 +8,9 @@ import { getWidthAndHeight } from "../app/DndProvider"; import type { StateT } from "../app/reducers"; import { getConceptById } from "../concept-trees/globalTreeStoreHelper"; import { - nodeHasNonDefaultSettings, - nodeHasFilterValues, nodeIsConceptQueryNode, canNodeBeDropped, + useActiveState, } from "../model/node"; import { isQueryExpandable } from "../model/query"; import { HoverNavigatable } from "../small-tab-navigation/HoverNavigatable"; @@ -93,21 +91,19 @@ const QueryNode = ({ onToggleTimestamps, onToggleSecondaryIdExclude, }: PropsT) => { - const { t } = useTranslation(); const rootNodeLabel = getRootNodeLabel(node); const ref = useRef(null); const activeSecondaryId = useSelector( (state) => state.queryEditor.selectedSecondaryId, ); - - const hasNonDefaultSettings = !node.error && nodeHasNonDefaultSettings(node); - const hasFilterValues = nodeHasFilterValues(node); const hasActiveSecondaryId = nodeHasActiveSecondaryId( node, activeSecondaryId, ); + const { active, tooltipText } = useActiveState(node); + const item: StandardQueryNodeT = { // Return the data describing the dragged item // NOT using `...node` since that would also spread `children` in. @@ -161,12 +157,6 @@ const QueryNode = ({ } as StandardQueryNodeT), }); - const tooltipText = hasNonDefaultSettings - ? t("queryEditor.hasNonDefaultSettings") - : hasFilterValues - ? t("queryEditor.hasDefaultSettings") - : undefined; - const expandClick = useCallback(() => { if (nodeIsConceptQueryNode(node) || !node.query) return; @@ -195,7 +185,7 @@ const QueryNode = ({ ref.current = instance; drag(instance); }} - active={hasNonDefaultSettings || hasFilterValues} + active={active} onClick={node.error ? undefined : () => onEditClick(andIdx, orIdx)} > { const isDatasetValid = validateDataset(datasetId); const hasQueryValidDates = validateQueryDates(query); const isQueryValid = validateQueryLength(query) && hasQueryValidDates; - const isQueryNotStartedOrStopped = validateQueryStartStop(queryRunner); + const queryStartStopReady = validateQueryStartStop(queryRunner); const buttonTooltip = useButtonTooltip(hasQueryValidDates); @@ -73,9 +73,7 @@ const StandardQueryRunner = () => { { {t("common.clear")} diff --git a/frontend/src/js/timebased-query-editor/TimebasedQueryRunner.tsx b/frontend/src/js/timebased-query-editor/TimebasedQueryRunner.tsx index 7b1a478c53..92aa7f668f 100644 --- a/frontend/src/js/timebased-query-editor/TimebasedQueryRunner.tsx +++ b/frontend/src/js/timebased-query-editor/TimebasedQueryRunner.tsx @@ -58,7 +58,7 @@ const TimebasedQueryRunner = () => { return ( ` +const Input = styled("input")<{ large?: boolean; disabled?: boolean }>` outline: 0; width: 100%; + opacity: ${({ disabled }) => (disabled ? 0.5 : 1)}; border: 1px solid ${({ theme }) => theme.col.grayMediumLight}; padding: ${({ large }) => @@ -86,6 +87,7 @@ export interface Props { large?: boolean; inputProps?: InputProps; currencyConfig?: CurrencyConfigT; + disabled?: boolean; onFocus?: (e: FocusEvent) => void; onBlur?: (e: FocusEvent) => void; onClick?: (e: React.MouseEvent) => void; @@ -131,6 +133,7 @@ const BaseInput = forwardRef( valid, invalid, invalidText, + disabled, }, ref, ) => { @@ -179,6 +182,7 @@ const BaseInput = forwardRef( }} value={exists(value) ? value : ""} large={large} + disabled={disabled} onFocus={onFocus} onBlur={onBlur} onClick={onClick} @@ -204,6 +208,7 @@ const BaseInput = forwardRef( tiny icon={faTimes} tabIndex={-1} + disabled={disabled} title={t("common.clearValue")} aria-label={t("common.clearValue")} onClick={() => onChange(null)} diff --git a/frontend/src/js/ui-components/Dropzone.tsx b/frontend/src/js/ui-components/Dropzone.tsx index e332607cfb..9662a49891 100644 --- a/frontend/src/js/ui-components/Dropzone.tsx +++ b/frontend/src/js/ui-components/Dropzone.tsx @@ -18,6 +18,7 @@ const Root = styled("div")<{ bare?: boolean; transparent?: boolean; canDrop?: boolean; + invisible?: boolean; }>` border: ${({ theme, isOver, canDrop, naked }) => naked @@ -29,7 +30,7 @@ const Root = styled("div")<{ : `3px dashed ${theme.col.grayMediumLight}`}; border-radius: ${({ theme }) => theme.borderRadius}; padding: ${({ bare }) => (bare ? "0" : "10px")}; - display: flex; + display: ${({ invisible }) => (invisible ? "none" : "flex")}; align-items: center; justify-content: center; background-color: ${({ theme, canDrop, naked, isOver, transparent }) => @@ -61,6 +62,7 @@ export interface DropzoneProps { naked?: boolean; bare?: boolean; transparent?: boolean; + invisible?: boolean; onDrop: (props: DroppableObject, monitor: DropTargetMonitor) => void; canDrop?: (props: DroppableObject, monitor: DropTargetMonitor) => boolean; onClick?: () => void; @@ -103,6 +105,7 @@ const Dropzone = ( canDrop, onDrop, onClick, + invisible, children, }: DropzoneProps, ref?: ForwardedRef, @@ -138,6 +141,7 @@ const Dropzone = ( } }} isOver={isOver} + invisible={invisible && !canDropResult} canDrop={canDropResult} className={className} onClick={onClick} diff --git a/frontend/src/js/ui-components/InputDate/CustomHeader.tsx b/frontend/src/js/ui-components/InputDate/CustomHeader.tsx index ca1269e39f..83b0b594c0 100644 --- a/frontend/src/js/ui-components/InputDate/CustomHeader.tsx +++ b/frontend/src/js/ui-components/InputDate/CustomHeader.tsx @@ -5,8 +5,10 @@ import { } from "@fortawesome/free-solid-svg-icons"; import { useState } from "react"; import { ReactDatePickerCustomHeaderProps } from "react-datepicker"; +import { useSelector } from "react-redux"; import { SelectOptionT } from "../../api/types"; +import { StateT } from "../../app/reducers"; import IconButton from "../../button/IconButton"; import { TransparentButton } from "../../button/TransparentButton"; import { useMonthName, useMonthNames } from "../../common/helpers/dateHelper"; @@ -79,13 +81,19 @@ const YearMonthSelect = ({ ReactDatePickerCustomHeaderProps, "date" | "changeYear" | "changeMonth" >) => { - const yearSelectionSpan = 10; - const yearOptions: SelectOptionT[] = [...Array(yearSelectionSpan).keys()] + const numLastYearsToShow = useSelector((state) => { + if (state.startup.config.observationPeriodStart) { + return ( + new Date().getFullYear() - + new Date(state.startup.config.observationPeriodStart).getFullYear() + ); + } else { + return 10; + } + }); + const yearOptions: SelectOptionT[] = [...Array(numLastYearsToShow).keys()] .map((n) => new Date().getFullYear() - n) - .map((year) => ({ - label: String(year), - value: year, - })) + .map((year) => ({ label: String(year), value: year })) .reverse(); const monthNames = useMonthNames(); diff --git a/frontend/src/js/ui-components/InputDateRange.tsx b/frontend/src/js/ui-components/InputDateRange.tsx index f8e0e46662..e58a00745e 100644 --- a/frontend/src/js/ui-components/InputDateRange.tsx +++ b/frontend/src/js/ui-components/InputDateRange.tsx @@ -1,5 +1,6 @@ import { css } from "@emotion/react"; import styled from "@emotion/styled"; +import { faCalendar } from "@fortawesome/free-regular-svg-icons"; import { FC, ReactNode, createRef, useMemo } from "react"; import { useTranslation } from "react-i18next"; @@ -12,6 +13,7 @@ import { DateStringMinMax, } from "../common/helpers/dateHelper"; import { exists } from "../common/helpers/exists"; +import { Icon } from "../icon/FaIcon"; import InfoTooltip from "../tooltip/InfoTooltip"; import InputDate from "./InputDate/InputDate"; @@ -33,7 +35,6 @@ const StyledLabel = styled(Label)<{ large?: boolean }>` large && css` font-size: ${theme.font.md}; - margin: 20px 0 10px; `} `; @@ -175,6 +176,7 @@ const InputDateRange: FC = ({ return ( + {exists(indexPrefix) && # {indexPrefix}} {optional && } {label} diff --git a/frontend/src/js/user/userSettings.ts b/frontend/src/js/user/userSettings.ts index 302cd9a79c..e9976531ac 100644 --- a/frontend/src/js/user/userSettings.ts +++ b/frontend/src/js/user/userSettings.ts @@ -2,12 +2,14 @@ const localStorage: Storage = window.localStorage; interface UserSettings { + showEditorV2: boolean; arePreviousQueriesFoldersOpen: boolean; preferredDownloadEnding?: string; // Usually CSV or XLSX preferredDownloadLabel?: string; // Label of the preferred Download format (e.g. "All files") } const initialState: UserSettings = { + showEditorV2: false, arePreviousQueriesFoldersOpen: false, preferredDownloadEnding: undefined, preferredDownloadLabel: undefined, diff --git a/frontend/src/localization/de.json b/frontend/src/localization/de.json index 1b5843d915..4d429dc251 100644 --- a/frontend/src/localization/de.json +++ b/frontend/src/localization/de.json @@ -2,9 +2,12 @@ "locale": "de", "headline": "Anfragen und Analyse", "error": { - "sorry": "Das hat leider nicht geklappt", - "description": "Versuche es noch einmal. Falls es erneut nicht klappt, bitte hinterlasse uns eine Nachricht, damit wir dieses Problem beheben können.", - "reload": "Seite vollständig neu laden" + "sorry": "Da ist etwas schiefgelaufen!", + "description": "Aber Du hast nichts falsch gemacht. Das Problem liegt auf unserer Seite.", + "reset": "Zurücksetzen", + "resetDescription": "Versuche zurückzusetzen. Falls das Problem weiterhin besteht, bitte kontaktiere uns, damit wir das Problem schneller beheben können.", + "reload": "Seite vollständig neu laden", + "reloadDescription": "Bitte hinterlasse uns eine Nachricht, damit wir dieses Problem beheben können." }, "errorCodes": { "EXAMPLE_ERROR": "Dies ist eine Beispiel-Fehlermeldung", @@ -17,7 +20,8 @@ "rightPane": { "queryEditor": "Editor", "timebasedQueryEditor": "Zeit-Editor", - "externalForms": "Formular-Editor" + "externalForms": "Formular-Editor", + "editorV2": "Wissenschaftlicher Editor" }, "conceptTreeList": { "loading": "Lade Konzepte", @@ -169,7 +173,8 @@ "dateInvalid": "Ungültiges Datum", "missingLabel": "Unbenannt", "import": "Importieren", - "openFileDialog": "Datei auswählen" + "openFileDialog": "Datei auswählen", + "shortcut": "Kurzbefehl" }, "tooltip": { "headline": "Info", @@ -442,6 +447,7 @@ "tabQueryEditor": "Hier kann eine Anfrage gestellt werden.", "tabTimebasedEditor": "Hier kann eine Zeit-Anfrage gestellt werden. Das heißt, dass mehrere Anfragen in zeitlichen Bezug gesetzt werden können.", "tabFormEditor": "Hier können Auswertungen und Analysen zu bestehenden Anfragen erstellt werden.", + "tabEditorV2": "Erweiterter Editor. Hier kann eine komplexe Anfrage gestellt werden.", "datasetSelector": "Auswahl des Datensatzes – die jeweiligen Konzepte, Anfragen und Formulare werden geladen.", "excludeTimestamps": "Auswahl führt dazu, dass die Datumswerte des Konzepts bei der Weiterverarbeitung nicht berücksichtigt werden.", "excludeFromSecondaryId": "Auswahl führt dazu, dass dieses Konzept nicht in der Analyse-Ebene berücksichtigt wird, falls eine Analyse-Ebene gewählt wurde.", @@ -453,6 +459,7 @@ "queryNodeDetails": "Detail-Einstellungen bearbeiten" }, "history": { + "blurred": "Daten-Sichtbarkeit", "emptyTimeline": { "headline": "Historie", "description": "Hier werden Informationen chronologisch dargestellt.", @@ -463,13 +470,13 @@ "backButtonWarning": "Achtung: Wenn Du zurück gehst, werden Änderungen an der Liste verworfen. Lade die Liste vorher herunter, um sie zu speichern.", "history": "Historie", "marked": "mit Status", - "events_one": "Datenpunkt", - "events_other": "Datenpunkte", + "events_one": "Eintrag", + "events_other": "Einträge", "downloadButtonLabel": "Liste mit Status-Einträgen herunterladen", "nextButtonLabel": "Weiterblättern", "prevButtonLabel": "Zurückblättern", "downloadEntityData": "Einzelhistorie herunterladen", - "differencesTooltip": "Unterschiede aus den einzelnen Datenpunkten", + "differencesTooltip": "Unterschiede aus den einzelnen Einträgen", "closeAll": "Alle schließen", "openAll": "Alle aufklappen", "dates": "Datumswerte", @@ -482,7 +489,7 @@ "detail": { "summary": "Zusammenfassung", "detail": "Gruppiert", - "full": "Alle Einzeldatenpunkte" + "full": "Alle Einzeleinträge" }, "content": { "money": "Geldbeträge", @@ -512,5 +519,38 @@ "paste": "Aus Zwischenablage einfügen", "submit": "Übernehmen", "pasted": "Importiert" + }, + "editorV2": { + "time": "ZEIT", + "and": "UND", + "or": "ODER", + "clear": "Editor vollständig zurücksetzen", + "clearConfirm": "Jetzt zurücksetzen", + "flip": "Drehen", + "dates": "Datum", + "negate": "Nicht", + "connector": "Verknüpfung", + "timeConnection": "Zeitverknüpfung", + "editTimeConnection": "Zeitverknüpfung bearbeiten", + "delete": "Löschen", + "expand": "Expandieren", + "edit": "Details", + "initialDropText": "Ziehe ein Konzept oder eine Anfrage hier hinein.", + "datesExcluded": "Keine Datumswerte", + "outputSection": "Ausgabewerte", + "filtersSection": "Filter", + "every": "Jeder", + "some": "Irgend ein", + "earliest": "Der früheste", + "latest": "Der späteste", + "dateRangeFrom": "Zeitraum aus", + "dayInterval": "Tage", + "intervalSome": "irgendwann", + "intervalMinDays": "mindestens {{days}} Tage", + "intervalMaxDays": "höchstens {{days}} Tage", + "intervalMinMaxDays": "zwischen {{minDays}} und {{maxDays}} Tagen", + "before": "zeitlich vor", + "after": "zeitlich nach", + "while": "zeitlich während" } } diff --git a/frontend/src/localization/en.json b/frontend/src/localization/en.json index 52d4c1f0ef..69aed6ea75 100644 --- a/frontend/src/localization/en.json +++ b/frontend/src/localization/en.json @@ -3,8 +3,11 @@ "headline": "Queries and Analyses", "error": { "sorry": "Sorry, something went wrong here", - "description": "Please try again. If this happens again, please leave us a message. That way, we can fix this issue.", - "reload": "Refresh page" + "description": "But it's not your fault. It's an issue on our side.", + "reset": "Reset", + "resetDescription": "Try to reset. If the issue happens again, please reach out to us. Then we can fix this issue sooner.", + "reload": "Refresh page", + "reloadDescription": "If this happens again, please leave us a message. That way, we can fix this issue sooner." }, "errorCodes": { "EXAMPLE_ERROR": "This is an example error", @@ -170,7 +173,8 @@ "dateInvalid": "Invalid date", "missingLabel": "Unknown", "import": "Import", - "openFileDialog": "Select file" + "openFileDialog": "Select file", + "shortcut": "Key" }, "tooltip": { "headline": "Info", @@ -443,6 +447,7 @@ "tabQueryEditor": "In the Editor, a query may be built and sent.", "tabTimebasedEditor": "In the Timebased Editor, a time-based query may be built and sent. That means, previous queries may be combined using time based relations, such as 'before' and 'after'.", "tabFormEditor": "The Form Editor allows for further analysis and statistics of previous queries.", + "tabEditorV2": "An extended editor that allows advanced queries.", "datasetSelector": "Select the dataset – concept trees, previous queries and forms will be loaded.", "excludeTimestamps": "If selected, will avoid using the date values from this concept within a query.", "excludeFromSecondaryId": "If selected, will avoid using this concept in the analysis layer, should an analysis layer be selected.", @@ -454,6 +459,7 @@ "queryNodeDetails": "Detail-Einstellungen bearbeiten" }, "history": { + "blurred": "Data visibility", "emptyTimeline": { "headline": "History", "description": "Exploring individual events chronologically.", @@ -513,5 +519,38 @@ "paste": "Paste from clipboard", "submit": "Submit", "pasted": "Imported" + }, + "editorV2": { + "time": "TIME", + "and": "AND", + "or": "OR", + "clear": "Reset editor completely", + "clearConfirm": "Reset now", + "flip": "Flip", + "dates": "Dates", + "negate": "Negate", + "connector": "Connector", + "timeConnection": "Time connection", + "editTimeConnection": "Edit time connection", + "delete": "Delete", + "expand": "Expand", + "edit": "Details", + "initialDropText": "Drop a concept or query here.", + "datesExcluded": "No dates", + "outputSection": "Output values", + "filtersSection": "Filters", + "every": "Every", + "some": "Some", + "earliest": "The earliest", + "latest": "The latest", + "dateRangeFrom": "date range from", + "dayInterval": "Days", + "intervalSome": "some time", + "intervalMinDays": "at least {{days}} days", + "intervalMaxDays": "at most {{days}} days", + "intervalMinMaxDays": "between {{minDays}} and {{maxDays}} days", + "before": "before", + "after": "after", + "while": "while" } } diff --git a/frontend/src/react-app-env.d.ts b/frontend/src/react-app-env.d.ts index 9cf631a06b..16604e8489 100644 --- a/frontend/src/react-app-env.d.ts +++ b/frontend/src/react-app-env.d.ts @@ -31,6 +31,12 @@ declare module "@emotion/react" { green: string; orange: string; palette: string[]; + fileTypes: { + csv: string; + pdf: string; + zip: string; + xlsx: string; + }; }; img: { logo: string; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 792f7e87d7..ae9546cbe7 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -1818,6 +1818,11 @@ dependencies: eslint-scope "5.1.1" +"@noble/hashes@^1.1.5": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.3.0.tgz#085fd70f6d7d9d109671090ccae1d3bec62554a1" + integrity sha512-ilHEACi9DwqJB0pw7kv+Apvh50jiiSyR/cQ3y4W7lOR5mhvn/50FLUfsnfJz0BDZtl/RR16kXvptiv6q1msYZg== + "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" @@ -1839,6 +1844,13 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" +"@paralleldrive/cuid2@^2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@paralleldrive/cuid2/-/cuid2-2.2.0.tgz#105b31311994c18d816cb0d31f31ecaf425d32b4" + integrity sha512-CVQDpPIUHrUGGLdrMGz1NmqZvqmsB2j2rCIQEu1EvxWjlFh4fhvEGmgR409cY20/67/WlJsggenq0no3p3kYsw== + dependencies: + "@noble/hashes" "^1.1.5" + "@popperjs/core@^2.9.0": version "2.11.6" resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.6.tgz#cee20bd55e68a1720bdab363ecf0c821ded4cd45"