From c353d067c11f0df1e4f96a0d6aadc4d8fd2d46a8 Mon Sep 17 00:00:00 2001 From: Lance Gliser Date: Fri, 7 Jun 2024 08:59:44 -0500 Subject: [PATCH] Added FeatureAttributeComponents --- .../en.json | 203 ++++++++ .../FeatureAttributeSample.stories.tsx | 42 ++ .../FeatureAttributeSample.tsx | 80 ++++ .../FeatureAttributeSample/index.ts | 1 + ...FeatureAttributesConfiguration.stories.tsx | 184 +++++++ .../FeatureAttributesConfiguration.tsx | 111 +++++ .../constants.ts | 3 + .../FeatureAttributesConfiguration/index.ts | 1 + ...FeaturesAttributesDependencies.stories.tsx | 151 ++++++ .../FeaturesAttributesDependencies.tsx | 189 ++++++++ .../FeaturesAttributesDependencies/index.ts | 1 + .../FeaturesAttributesForms.stories.tsx | 251 ++++++++++ .../FeaturesAttributesForms.test.tsx | 202 ++++++++ .../FeaturesAttributesForms.tsx | 453 ++++++++++++++++++ .../FeaturesAttributesForms/constants.ts | 15 + .../FeaturesAttributesForms/index.ts | 1 + .../FeaturesAttributesForms/utils.ts | 93 ++++ ...eatureAttributeAllowNullsField.stories.tsx | 27 ++ .../FeatureAttributeAllowNullsField.test.tsx | 68 +++ .../FeatureAttributeAllowNullsField.tsx | 37 ++ .../constants.ts | 3 + .../FeatureAttributeAllowNullsField/index.ts | 2 + ...ureAttributeAllowedValuesField.stories.tsx | 56 +++ ...eatureAttributeAllowedValuesField.test.tsx | 121 +++++ .../FeatureAttributeAllowedValuesField.tsx | 176 +++++++ .../constants.ts | 5 + .../index.ts | 2 + ...atureAttributeCycleLengthField.stories.tsx | 30 ++ .../FeatureAttributeCycleLengthField.tsx | 46 ++ .../FeatureAttributeCycleLengthField/index.ts | 1 + .../FeatureAttributeDataTypeField.stories.tsx | 37 ++ .../FeatureAttributeDataTypeField.test.tsx | 10 + .../FeatureAttributeDataTypeField.tsx | 93 ++++ .../constants.ts | 2 + .../FeatureAttributeDataTypeField/index.ts | 1 + ...reAttributeDateTimeFormatField.stories.tsx | 29 ++ .../FeatureAttributeDateTimeFormatField.tsx | 59 +++ .../constants.ts | 2 + .../index.ts | 2 + ...ureAttributeDecimalPlacesField.stories.tsx | 30 ++ .../FeatureAttributeDecimalPlacesField.tsx | 50 ++ .../index.ts | 1 + ...tributeDerivedFeatureCodeField.stories.tsx | 27 ++ ...eatureAttributeDerivedFeatureCodeField.tsx | 55 +++ .../index.ts | 1 + ...FeatureAttributeIdFeatureField.stories.tsx | 31 ++ .../FeatureAttributeIdFeatureField.tsx | 43 ++ .../FeatureAttributeIdFeatureField/index.ts | 1 + ...atureAttributeIsSensitiveField.stories.tsx | 35 ++ .../FeatureAttributeIsSensitiveField.tsx | 60 +++ .../FeatureAttributeIsSensitiveField.utils.ts | 16 + .../FeatureAttributeIsSensitiveField/index.ts | 2 + .../FeatureAttributeLocaleField.stories.tsx | 27 ++ .../FeatureAttributeLocaleField.test.tsx | 16 + .../FeatureAttributeLocaleField.tsx | 65 +++ .../FeatureAttributeLocaleField/constants.ts | 2 + .../FeatureAttributeLocaleField/index.ts | 1 + .../FeatureAttributeMinMaxFields.stories.tsx | 44 ++ .../FeatureAttributeMinMaxFields.tsx | 62 +++ .../FeatureAttributeMinMaxFields/index.ts | 1 + ...eAttributeNullIsDependentField.stories.tsx | 29 ++ .../FeatureAttributeNullIsDependentField.tsx | 41 ++ .../index.ts | 1 + ...tributeObservationalErrorField.stories.tsx | 65 +++ ...eatureAttributeObservationalErrorField.tsx | 72 +++ .../index.ts | 1 + ...atureAttributePostProcessField.stories.tsx | 27 ++ .../FeatureAttributePostProcessField.tsx | 50 ++ .../FeatureAttributePostProcessField/index.ts | 1 + ...ttributeSignificantDigitsField.stories.tsx | 30 ++ ...FeatureAttributeSignificantDigitsField.tsx | 52 ++ .../index.ts | 1 + .../FeatureAttributeSubtypeField.stories.tsx | 38 ++ .../FeatureAttributeSubtypeField.test.tsx | 168 +++++++ .../FeatureAttributeSubtypeField.tsx | 110 +++++ .../FeatureAttributeSubtypeField/constants.ts | 3 + .../FeatureAttributeSubtypeField/index.ts | 2 + ...uteTimeSeriesDeltaMinMaxFields.stories.tsx | 41 ++ ...reAttributeTimeSeriesDeltaMinMaxFields.tsx | 53 ++ .../index.ts | 1 + ...teTimeSeriesDerivedOrdersField.stories.tsx | 35 ++ ...eAttributeTimeSeriesDerivedOrdersField.tsx | 35 ++ .../index.ts | 1 + ...eTimeSeriesHasTerminatorsField.stories.tsx | 76 +++ ...AttributeTimeSeriesHasTerminatorsField.tsx | 33 ++ .../index.ts | 1 + ...reAttributeTimeSeriesLagsField.stories.tsx | 27 ++ .../FeatureAttributeTimeSeriesLagsField.tsx | 26 + .../index.ts | 1 + ...ttributeTimeSeriesNumLagsField.stories.tsx | 33 ++ ...FeatureAttributeTimeSeriesNumLagsField.tsx | 35 ++ .../index.ts | 1 + ...eAttributeTimeSeriesOrderField.stories.tsx | 27 ++ .../FeatureAttributeTimeSeriesOrderField.tsx | 27 ++ .../index.ts | 1 + ...buteTimeSeriesRateMinMaxFields.stories.tsx | 29 ++ ...ureAttributeTimeSeriesRateMinMaxFields.tsx | 50 ++ .../index.ts | 1 + ...meSeriesStopOnTerminatorsField.stories.tsx | 35 ++ ...ributeTimeSeriesStopOnTerminatorsField.tsx | 33 ++ .../index.ts | 1 + ...reAttributeTimeSeriesTypeField.stories.tsx | 29 ++ .../FeatureAttributeTimeSeriesTypeField.tsx | 59 +++ .../index.ts | 1 + .../FeatureAttributeTypeField.stories.tsx | 27 ++ .../FeatureAttributeTypeField.test.tsx | 10 + .../FeatureAttributeTypeField.tsx | 116 +++++ .../FeatureAttributeTypeField/constants.ts | 6 + .../fields/FeatureAttributeTypeField/index.ts | 2 + .../FeatureAttributeUniqueField.stories.tsx | 31 ++ .../FeatureAttributeUniqueField.tsx | 41 ++ .../FeatureAttributeUniqueField/index.ts | 1 + .../FeatureAttributes/fields/index.ts | 27 ++ src/components/FeatureAttributes/index.ts | 2 +- src/components/FeatureAttributes/types/api.ts | 60 +++ .../FeatureAttributes/types/index.ts | 0 116 files changed, 5065 insertions(+), 1 deletion(-) create mode 100644 src/components/FeatureAttributes/FeatureAttributeSample/FeatureAttributeSample.stories.tsx create mode 100644 src/components/FeatureAttributes/FeatureAttributeSample/FeatureAttributeSample.tsx create mode 100644 src/components/FeatureAttributes/FeatureAttributeSample/index.ts create mode 100644 src/components/FeatureAttributes/FeatureAttributesConfiguration/FeatureAttributesConfiguration.stories.tsx create mode 100644 src/components/FeatureAttributes/FeatureAttributesConfiguration/FeatureAttributesConfiguration.tsx create mode 100644 src/components/FeatureAttributes/FeatureAttributesConfiguration/constants.ts create mode 100644 src/components/FeatureAttributes/FeatureAttributesConfiguration/index.ts create mode 100644 src/components/FeatureAttributes/FeaturesAttributesDependencies/FeaturesAttributesDependencies.stories.tsx create mode 100644 src/components/FeatureAttributes/FeaturesAttributesDependencies/FeaturesAttributesDependencies.tsx create mode 100644 src/components/FeatureAttributes/FeaturesAttributesDependencies/index.ts create mode 100644 src/components/FeatureAttributes/FeaturesAttributesForms/FeaturesAttributesForms.stories.tsx create mode 100644 src/components/FeatureAttributes/FeaturesAttributesForms/FeaturesAttributesForms.test.tsx create mode 100644 src/components/FeatureAttributes/FeaturesAttributesForms/FeaturesAttributesForms.tsx create mode 100644 src/components/FeatureAttributes/FeaturesAttributesForms/constants.ts create mode 100644 src/components/FeatureAttributes/FeaturesAttributesForms/index.ts create mode 100644 src/components/FeatureAttributes/FeaturesAttributesForms/utils.ts create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeAllowNullsField/FeatureAttributeAllowNullsField.stories.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeAllowNullsField/FeatureAttributeAllowNullsField.test.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeAllowNullsField/FeatureAttributeAllowNullsField.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeAllowNullsField/constants.ts create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeAllowNullsField/index.ts create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeAllowedValuesField/FeatureAttributeAllowedValuesField.stories.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeAllowedValuesField/FeatureAttributeAllowedValuesField.test.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeAllowedValuesField/FeatureAttributeAllowedValuesField.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeAllowedValuesField/constants.ts create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeAllowedValuesField/index.ts create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeCycleLengthField/FeatureAttributeCycleLengthField.stories.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeCycleLengthField/FeatureAttributeCycleLengthField.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeCycleLengthField/index.ts create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeDataTypeField/FeatureAttributeDataTypeField.stories.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeDataTypeField/FeatureAttributeDataTypeField.test.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeDataTypeField/FeatureAttributeDataTypeField.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeDataTypeField/constants.ts create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeDataTypeField/index.ts create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeDateTimeFormatField/FeatureAttributeDateTimeFormatField.stories.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeDateTimeFormatField/FeatureAttributeDateTimeFormatField.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeDateTimeFormatField/constants.ts create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeDateTimeFormatField/index.ts create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeDecimalPlacesField/FeatureAttributeDecimalPlacesField.stories.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeDecimalPlacesField/FeatureAttributeDecimalPlacesField.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeDecimalPlacesField/index.ts create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeDerivedFeatureCodeField/FeatureAttributeDerivedFeatureCodeField.stories.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeDerivedFeatureCodeField/FeatureAttributeDerivedFeatureCodeField.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeDerivedFeatureCodeField/index.ts create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeIdFeatureField/FeatureAttributeIdFeatureField.stories.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeIdFeatureField/FeatureAttributeIdFeatureField.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeIdFeatureField/index.ts create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeIsSensitiveField/FeatureAttributeIsSensitiveField.stories.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeIsSensitiveField/FeatureAttributeIsSensitiveField.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeIsSensitiveField/FeatureAttributeIsSensitiveField.utils.ts create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeIsSensitiveField/index.ts create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeLocaleField/FeatureAttributeLocaleField.stories.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeLocaleField/FeatureAttributeLocaleField.test.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeLocaleField/FeatureAttributeLocaleField.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeLocaleField/constants.ts create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeLocaleField/index.ts create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeMinMaxFields/FeatureAttributeMinMaxFields.stories.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeMinMaxFields/FeatureAttributeMinMaxFields.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeMinMaxFields/index.ts create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeNullIsDependentField/FeatureAttributeNullIsDependentField.stories.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeNullIsDependentField/FeatureAttributeNullIsDependentField.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeNullIsDependentField/index.ts create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeObservationalErrorField/FeatureAttributeObservationalErrorField.stories.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeObservationalErrorField/FeatureAttributeObservationalErrorField.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeObservationalErrorField/index.ts create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributePostProcessField/FeatureAttributePostProcessField.stories.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributePostProcessField/FeatureAttributePostProcessField.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributePostProcessField/index.ts create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeSignificantDigitsField/FeatureAttributeSignificantDigitsField.stories.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeSignificantDigitsField/FeatureAttributeSignificantDigitsField.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeSignificantDigitsField/index.ts create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeSubtypeField/FeatureAttributeSubtypeField.stories.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeSubtypeField/FeatureAttributeSubtypeField.test.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeSubtypeField/FeatureAttributeSubtypeField.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeSubtypeField/constants.ts create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeSubtypeField/index.ts create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeTimeSeriesDeltaMinMaxFields/FeatureAttributeTimeSeriesDeltaMinMaxFields.stories.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeTimeSeriesDeltaMinMaxFields/FeatureAttributeTimeSeriesDeltaMinMaxFields.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeTimeSeriesDeltaMinMaxFields/index.ts create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeTimeSeriesDerivedOrdersField/FeatureAttributeTimeSeriesDerivedOrdersField.stories.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeTimeSeriesDerivedOrdersField/FeatureAttributeTimeSeriesDerivedOrdersField.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeTimeSeriesDerivedOrdersField/index.ts create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeTimeSeriesHasTerminatorsField/FeatureAttributeTimeSeriesHasTerminatorsField.stories.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeTimeSeriesHasTerminatorsField/FeatureAttributeTimeSeriesHasTerminatorsField.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeTimeSeriesHasTerminatorsField/index.ts create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeTimeSeriesLagsField/FeatureAttributeTimeSeriesLagsField.stories.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeTimeSeriesLagsField/FeatureAttributeTimeSeriesLagsField.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeTimeSeriesLagsField/index.ts create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeTimeSeriesNumLagsField/FeatureAttributeTimeSeriesNumLagsField.stories.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeTimeSeriesNumLagsField/FeatureAttributeTimeSeriesNumLagsField.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeTimeSeriesNumLagsField/index.ts create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeTimeSeriesOrderField/FeatureAttributeTimeSeriesOrderField.stories.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeTimeSeriesOrderField/FeatureAttributeTimeSeriesOrderField.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeTimeSeriesOrderField/index.ts create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeTimeSeriesRateMinMaxFields/FeatureAttributeTimeSeriesRateMinMaxFields.stories.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeTimeSeriesRateMinMaxFields/FeatureAttributeTimeSeriesRateMinMaxFields.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeTimeSeriesRateMinMaxFields/index.ts create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeTimeSeriesStopOnTerminatorsField/FeatureAttributeTimeSeriesStopOnTerminatorsField.stories.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeTimeSeriesStopOnTerminatorsField/FeatureAttributeTimeSeriesStopOnTerminatorsField.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeTimeSeriesStopOnTerminatorsField/index.ts create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeTimeSeriesTypeField/FeatureAttributeTimeSeriesTypeField.stories.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeTimeSeriesTypeField/FeatureAttributeTimeSeriesTypeField.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeTimeSeriesTypeField/index.ts create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeTypeField/FeatureAttributeTypeField.stories.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeTypeField/FeatureAttributeTypeField.test.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeTypeField/FeatureAttributeTypeField.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeTypeField/constants.ts create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeTypeField/index.ts create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeUniqueField/FeatureAttributeUniqueField.stories.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeUniqueField/FeatureAttributeUniqueField.tsx create mode 100644 src/components/FeatureAttributes/fields/FeatureAttributeUniqueField/index.ts create mode 100644 src/components/FeatureAttributes/fields/index.ts create mode 100644 src/components/FeatureAttributes/types/api.ts create mode 100644 src/components/FeatureAttributes/types/index.ts diff --git a/public/locales/howso-engine-react-display-components/en.json b/public/locales/howso-engine-react-display-components/en.json index f952b17..1c2fd67 100644 --- a/public/locales/howso-engine-react-display-components/en.json +++ b/public/locales/howso-engine-react-display-components/en.json @@ -1,5 +1,97 @@ { "FeatureAttributes": { + "FeatureAttributeAllowedValuesField": { + "help": { + "nominal": "Use a new line for each value.", + "ordinal": "Use a new line for each value. Order matters." + }, + "label": { + "nominal": "Allowed Values in Order", + "ordinal": "Allowed Values" + } + }, + "FeatureAttributeAllowNullsField": { + "label": "Allow Nulls" + }, + "FeatureAttributeCycleLengthField": { + "help": "Only required if your feature is cyclical, such as days of the week.", + "label": "Cycle Length" + }, + "FeatureAttributeDataTypeField": { + "help": { + "formattedDateTime": "Formatted Date Time supports string based features such as ISO 8601 or your own custom formats. For numeric date times such as epoch numbers, use a Continuous Number." + }, + "label": "Data Type", + "options": { + "amalgam": "Amalgam", + "boolean": "Boolean", + "formatted_date_time": "Formatted Date Time", + "groups": { + "continuous": "Continuous", + "nominal": "Nominal" + }, + "json": "JSON", + "number": "Number", + "string": "String", + "string_mixable": "String Mixable", + "yaml": "YAML" + } + }, + "FeatureAttributeDateTimeFormatField": { + "help": "Any valid <1>standard format specification format.", + "label": "Date Time Format" + }, + "FeatureAttributeDecimalPlacesField": { + "help": "Round to the specified decimal places. An empty value will result in no rounding. If Significant Digits is also specified, the number will be rounded to the specified number of significant digits first, then rounded to the number of decimal points as specified by this parameter.", + "label": "Decimal Places" + }, + "FeatureAttributeDerivedFeatureCodeField": { + "help": "<0>Amalgam code defining how the value for this feature could be derived if this feature is specified as a `derived_context_feature` or a `derived_action_feature` during `react` flows.", + "label": "Derived Feature Code" + }, + "FeatureAttributeIdFeatureField": { + "help": "Set to true for nominal features containing nominal IDs, specifying that this feature should be used to compute case weights for id based privacy. For time series, this feature will be used as the id for each time series generation.", + "label": "ID Feature" + }, + "FeatureAttributeIsSensitiveField": { + "help": "By default, all data is treated as sensitive, you can change data to be non-sensitive, allowing the values to be re-used in the generation of synthetic data.", + "label": "Sensitive" + }, + "FeatureAttributeLocaleField": { + "help": "The <1>ISO-639 Language Code with optional <3>ISO-3166 Country Code. Locales are used during synthesis. Defaults for the data set will be used if not explicitly defined for this feature. Additional locale options may be specified during synthesis configuration.", + "label": "Locale" + }, + "FeatureAttributeMinMaxFields": { + "label": { + "max": "Max", + "min": "Min" + } + }, + "FeatureAttributeNullIsDependentField": { + "help": { + "dependencies": "Dependent Features", + "description": "Modify how dependent features with `null`s are treated during a `react`, specifically when they use `null` as a context value. When `false` (default), the feature will be treated as a non-dependent context feature. When `true` for nominal types, treats null as an individual dependent class value, only cases that also have `null`s as this feature's value will be considered. When true for continuous types, only the cases with the same dependent feature values as the cases that also have nulls as this feature's value will be considered." + }, + "label": "Null is Dependent" + }, + "FeatureAttributeObservationalErrorField": { + "help": { + "default": "Specify the mean absolute error for this feature. Defaults to 0.", + "nominal": "Specify known probability (0-1) of misclassification. Defaults to 0.", + "ordinal": "Specify known probability of misclassification through one or more adjacent values. Defaults to 0.", + "string": "Specify known probability (0-1) of misclassification. Defaults to 0." + }, + "label": "Observational Error" + }, + "FeatureAttributePostProcessField": { + "help": "<0>Amalgam code that is called on resulting values of this feature during `react` operations.", + "label": "Post Process Code" + }, + "FeatureAttributeSample": { + "modal": { + "title": "Sample" + } + }, "FeatureAttributesBoundsGroup": { "title": "Bounds" }, @@ -9,11 +101,122 @@ "FeatureAttributesGroupBase": { "expandControl": "Advanced options" }, + "FeatureAttributeSignificantDigitsField": { + "help": "Round to the specified significant digits. An empty value will result in no rounding.", + "label": "Significant Digits" + }, "FeatureAttributesProgrammableGroup": { "title": "Programmatic Features" }, "FeatureAttributesTemporalityGroup": { "title": "Temporality" + }, + "FeatureAttributeSubtypeField": { + "help": "Your platform supports a default list of options. You may create your own, supplying the name here instead.", + "label": "Subtype" + }, + "FeatureAttributeTimeSeriesDeltaMinMaxFields": { + "help": "Constraints for the delta of this feature. No value means no boundary. The length of the list must match the number of derivatives as specified by Order.", + "label": { + "max": "Delta Max", + "min": "Delta Min" + } + }, + "FeatureAttributeTimeSeriesDerivedOrdersField": { + "help": "The number of orders of derivatives that should be derived instead of synthesized. Ignored if Order is not provided.", + "label": "Derived Orders" + }, + "FeatureAttributeTimeSeriesHasTerminatorsField": { + "help": "Require the model identify and learn values that explicitly denote the end of a series.", + "label": "Series has Terminators" + }, + "FeatureAttributeTimeSeriesLagsField": { + "help": "If specified, generates lag features containing previous values using the enumerated lag offsets. Takes precedence over Number of Lags. If neither Number of Lags nor Lags is specified for a feature, then a single lag feature is generated.", + "label": "Lags" + }, + "FeatureAttributeTimeSeriesNumLagsField": { + "help": "If provided, will generate the specified number of derivatives and boundary values.", + "label": "Number of Lags" + }, + "FeatureAttributeTimeSeriesOrderField": { + "help": "If provided, will generate the specified number of derivatives and boundary values.", + "label": "Order" + }, + "FeatureAttributeTimeSeriesRateMinMaxFields": { + "help": "Constraints for the rate or delta (the difference quotient, the discrete version of derivative) of this feature. A `null` value means no min boundary. The value must be in epoch format for the time feature. The length of the list must match the number of derivatives as specified by order.", + "label": { + "max": "Max", + "min": "Min" + } + }, + "FeatureAttributeTimeSeriesStopOnTerminatorsField": { + "help": "Require that a series ends on a terminator value.", + "label": "Stop on Terminator" + }, + "FeatureAttributeTimeSeriesTypeField": { + "help": "When `rate` is specified, uses the difference of the current value from its previous value divided by the change in time since the previous value. When `delta` is specified, uses the difference of the current value from its previous value regardless of the elapsed time.", + "label": "Type", + "options": { + "delta": "Delta", + "rate": "Rate" + } + }, + "FeatureAttributeTypeField": { + "help": { + "continuous": { + "description": "A continuous numeric value.", + "example": "e.g. Temperature or humidity." + }, + "nominal": { + "description": "A value with no ordering.", + "example": "e.g. The name of a fruit." + }, + "ordinal": { + "description": "A nominal value with specific ordering.", + "example": "e.g. Rating scale, 1-5 stars." + } + }, + "label": "Type", + "options": { + "continuous": "Continuous", + "nominal": "Nominal", + "ordinal": "Ordinal" + } + }, + "FeatureAttributeUniqueField": { + "label": "Unique" + }, + "FeaturesAttributesDependencies": { + "actions": { + "update": "Update" + }, + "help": "Select features with inter-dependent relationships. This should be used when there are multi-type features that tightly depend on other multi-type features. Setting `null` values may effect dependencies. `Null`s can be managed through the feature's configurations.", + "state": { + "empty": "No features were found in the dataset." + } + }, + "FeaturesAttributesForms": { + "actions": { + "configure": "Configure", + "configure_{{name}}": "Configure {{name}}", + "mapDependents": "Map dependents", + "update": "Update", + "updateAndGoTo_{{target}}": "Update & go to: {{target}}" + }, + "form": { + "label": "Configure feature" + }, + "headings": { + "configuration": "Configuration", + "feature": "Feature", + "sample": "Sample", + "timeFeature": "Time feature", + "timeSeries": "Time series", + "type": "Type" + }, + "state": { + "empty": "No features were found in the dataset." + } } } } diff --git a/src/components/FeatureAttributes/FeatureAttributeSample/FeatureAttributeSample.stories.tsx b/src/components/FeatureAttributes/FeatureAttributeSample/FeatureAttributeSample.stories.tsx new file mode 100644 index 0000000..d8b3d32 --- /dev/null +++ b/src/components/FeatureAttributes/FeatureAttributeSample/FeatureAttributeSample.stories.tsx @@ -0,0 +1,42 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { FeatureAttributeSample } from "./FeatureAttributeSample"; + +// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction#default-export +const meta: Meta = { + component: FeatureAttributeSample, + // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/7.0/react/writing-docs/docs-page + tags: ["autodocs"], + parameters: { + // More on Story layout: https://storybook.js.org/docs/react/configure/story-layout + layout: "centered", + }, + // More on argTypes: https://storybook.js.org/docs/react/api/argtypes + argTypes: {}, + args: {}, +}; + +export default meta; +type Story = StoryObj; + +// More on component templates: https://storybook.js.org/docs/react/writing-stories/introduction#using-args +export const Default: Story = { + // More on args: https://storybook.js.org/docs/react/writing-stories/args + args: { + attributes: { + data_type: "string", + sample: "The United Kingdom of Great Britain and Northern Ireland", + }, + }, +}; + +export const JSON: Story = { + args: { + attributes: { + data_type: "json", + sample: `{ + height: "6\\"2'", + weight: "174 lbs" + }`, + }, + }, +}; diff --git a/src/components/FeatureAttributes/FeatureAttributeSample/FeatureAttributeSample.tsx b/src/components/FeatureAttributes/FeatureAttributeSample/FeatureAttributeSample.tsx new file mode 100644 index 0000000..2c3ffdf --- /dev/null +++ b/src/components/FeatureAttributes/FeatureAttributeSample/FeatureAttributeSample.tsx @@ -0,0 +1,80 @@ +import { FeatureAttributes } from "@howso/openapi-client"; +import { useDefaultTranslation } from "@/hooks/useDefaultTranslation"; +import { Modal } from "flowbite-react"; +import { FC, useState } from "react"; + +export type FeatureAttributeSampleProps = { + attributes: Pick; +}; +export const FeatureAttributeSample: FC = ({ + attributes, +}) => { + console.info("attributes", attributes); + const [isOpen, setIsOpen] = useState(false); + const openModal = () => { + setIsOpen(true); + }; + const closeModal = () => { + setIsOpen(false); + }; + + switch (true) { + case attributes.data_type === "json": + case attributes.data_type === "yaml": + return ( + <> + + {isOpen && ( + + )} + + ); + case attributes.sample === null: + return null; + case attributes.data_type === "boolean": + return ( + + {attributes.sample} + + ); + case attributes.data_type === "amalgam": + case attributes.data_type === "string": + case attributes.data_type === "string_mixable": + case attributes.data_type === "formatted_date_time": + return "{attributes.sample}"; + default: + return {attributes.sample}; + } +}; + +const FeatureAttributeSampleModal: FC< + FeatureAttributeSampleProps & { onClose: () => void } +> = ({ attributes, onClose }) => { + const { t } = useDefaultTranslation(); + return ( + + + {t("FeatureAttributes.FeatureAttributeSample.modal.title")} + + +
+          
+            {typeof attributes.sample === "string"
+              ? attributes.sample
+              : JSON.stringify(attributes.sample)}
+          
+        
+
+
+ ); +}; diff --git a/src/components/FeatureAttributes/FeatureAttributeSample/index.ts b/src/components/FeatureAttributes/FeatureAttributeSample/index.ts new file mode 100644 index 0000000..8fe805e --- /dev/null +++ b/src/components/FeatureAttributes/FeatureAttributeSample/index.ts @@ -0,0 +1 @@ +export * from "./FeatureAttributeSample"; diff --git a/src/components/FeatureAttributes/FeatureAttributesConfiguration/FeatureAttributesConfiguration.stories.tsx b/src/components/FeatureAttributes/FeatureAttributesConfiguration/FeatureAttributesConfiguration.stories.tsx new file mode 100644 index 0000000..fa385d9 --- /dev/null +++ b/src/components/FeatureAttributes/FeatureAttributesConfiguration/FeatureAttributesConfiguration.stories.tsx @@ -0,0 +1,184 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { getFormProviderDecorator, withPadding } from "@/storybook"; +import { FeatureAttributesConfiguration } from "./FeatureAttributesConfiguration"; +import { type FeatureAttributesFieldsValues } from "./constants"; + +// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction#default-export +const meta: Meta = { + component: FeatureAttributesConfiguration, + // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/7.0/react/writing-docs/docs-page + tags: ["autodocs"], + parameters: { + // More on Story layout: https://storybook.js.org/docs/react/configure/story-layout + layout: "fullscreen", + }, + decorators: [ + getFormProviderDecorator(), + withPadding, + ], + // More on argTypes: https://storybook.js.org/docs/react/api/argtypes + argTypes: {}, + args: { + featuresHaveTimeFeature: false, + }, +}; + +export default meta; +type Story = StoryObj; + +export const ContinuousNumber: Story = { + decorators: [ + getFormProviderDecorator({ + defaultValues: { + type: "continuous", + data_type: "number", + decimal_places: 0, + }, + }), + ], + args: {}, +}; + +export const ContinuousString: Story = { + decorators: [ + getFormProviderDecorator({ + defaultValues: { + type: "continuous", + data_type: "string", + }, + }), + ], + args: {}, +}; + +export const ContinuousFormattedDateTime: Story = { + decorators: [ + getFormProviderDecorator({ + defaultValues: { + type: "continuous", + data_type: "formatted_date_time", + date_time_format: "DD/MM/YYYY", + }, + }), + ], + args: { + featuresHaveTimeFeature: true, + }, +}; + +export const ContinuousComplex: Story = { + decorators: [ + getFormProviderDecorator({ + defaultValues: { + type: "continuous", + data_type: "json", + }, + }), + ], + args: {}, +}; + +export const NominalNumber: Story = { + decorators: [ + getFormProviderDecorator({ + defaultValues: { + type: "nominal", + data_type: "number", + }, + }), + ], + args: {}, +}; + +export const NominalString: Story = { + decorators: [ + getFormProviderDecorator({ + defaultValues: { + type: "nominal", + data_type: "string", + bounds: { + allow_null: true, + }, + }, + }), + ], + args: {}, +}; + +export const NominalFormattedDateTime: Story = { + decorators: [ + getFormProviderDecorator({ + defaultValues: { + type: "nominal", + data_type: "formatted_date_time", + date_time_format: "DD/MM/YYYY", + bounds: { + allow_null: true, + }, + }, + }), + ], + args: { + featuresHaveTimeFeature: true, + }, +}; + +export const NominalComplex: Story = { + decorators: [ + getFormProviderDecorator({ + defaultValues: { + type: "nominal", + data_type: "json", + }, + }), + ], + args: {}, +}; + +export const OrdinalNumber: Story = { + decorators: [ + getFormProviderDecorator({ + defaultValues: { + type: "ordinal", + data_type: "number", + bounds: { + allow_null: false, + }, + }, + }), + ], + args: {}, +}; + +export const OrdinalString: Story = { + decorators: [ + getFormProviderDecorator({ + defaultValues: { + type: "ordinal", + data_type: "string", + bounds: { + allow_null: false, + }, + }, + }), + ], + args: {}, +}; + +export const OrdinalFormattedDateTime: Story = { + decorators: [ + getFormProviderDecorator({ + defaultValues: { + type: "ordinal", + data_type: "formatted_date_time", + date_time_format: "DD/MM/YYYY", + bounds: { + allow_null: true, + }, + }, + }), + ], + args: { + featuresHaveTimeFeature: true, + }, +}; diff --git a/src/components/FeatureAttributes/FeatureAttributesConfiguration/FeatureAttributesConfiguration.tsx b/src/components/FeatureAttributes/FeatureAttributesConfiguration/FeatureAttributesConfiguration.tsx new file mode 100644 index 0000000..f1c7b19 --- /dev/null +++ b/src/components/FeatureAttributes/FeatureAttributesConfiguration/FeatureAttributesConfiguration.tsx @@ -0,0 +1,111 @@ +import { type PropsWithChildren } from "react"; +import { twMerge } from "tailwind-merge"; +import { + FeatureAttributeTypeField, + FeatureAttributeUniqueField, + FeatureAttributeSubtypeField, + FeatureAttributeDataTypeField, + FeatureAttributeIdFeatureField, + FeatureAttributeIsSensitiveField, + FeatureAttributeLocaleField, + FeatureAttributeDateTimeFormatField, + FeatureAttributeObservationalErrorField, + FeatureAttributeNullIsDependentField, +} from "../fields"; +import { + FeatureAttributesContinuousNumbersGroup, + FeatureAttributesBoundsGroup, + FeatureAttributesProgrammableGroup, +} from "../groups"; +import { FeatureAttributesTemporalityGroup } from "../groups/FeatureAttributesTemporalityGroup"; +import { useFormValues } from "@/hooks/useFormValues"; +import { type FeatureAttributesFieldsValues } from "./constants"; +import { formSpacingYDefault } from "@howso/react-tailwind-flowbite-components"; + +export interface FeatureAttributesConfigurationProps extends PropsWithChildren { + className?: string; + /** If any feature in the data has a time feature */ + featuresHaveTimeFeature: boolean; +} + +/** + * Allows the user to manipulate the type, data type, and dependent FeatureAttribute fields. + */ +export function FeatureAttributesConfiguration({ + children, + className, + featuresHaveTimeFeature, +}: FeatureAttributesConfigurationProps) { + const values = useFormValues(); + const { + type: featureType, + data_type: dataType, + id_feature: isIdFeature, + date_time_format: dateTimeFormat, + dependent_features: dependentFeatures, + non_sensitive: nonSensitive, + time_series: timeSeries, + } = values; + const isTimeFeature = timeSeries?.time_feature; + + return ( +
+ + + + + + + + + + + + + + + + + + + + + + + {children} +
+ ); +} diff --git a/src/components/FeatureAttributes/FeatureAttributesConfiguration/constants.ts b/src/components/FeatureAttributes/FeatureAttributesConfiguration/constants.ts new file mode 100644 index 0000000..1ed3671 --- /dev/null +++ b/src/components/FeatureAttributes/FeatureAttributesConfiguration/constants.ts @@ -0,0 +1,3 @@ +import { FeatureAttributes } from "@howso/openapi-client"; + +export type FeatureAttributesFieldsValues = FeatureAttributes; diff --git a/src/components/FeatureAttributes/FeatureAttributesConfiguration/index.ts b/src/components/FeatureAttributes/FeatureAttributesConfiguration/index.ts new file mode 100644 index 0000000..9eb23ee --- /dev/null +++ b/src/components/FeatureAttributes/FeatureAttributesConfiguration/index.ts @@ -0,0 +1 @@ +export * from "./FeatureAttributesConfiguration"; diff --git a/src/components/FeatureAttributes/FeaturesAttributesDependencies/FeaturesAttributesDependencies.stories.tsx b/src/components/FeatureAttributes/FeaturesAttributesDependencies/FeaturesAttributesDependencies.stories.tsx new file mode 100644 index 0000000..772d817 --- /dev/null +++ b/src/components/FeatureAttributes/FeaturesAttributesDependencies/FeaturesAttributesDependencies.stories.tsx @@ -0,0 +1,151 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { fn } from "@storybook/test"; +import { FeaturesAttributesDependencies } from "./FeaturesAttributesDependencies"; +import { + FeatureAttributesIndex, + getFeaturesAttributesIndexAtom, +} from "../utils"; + +// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction#default-export +const meta: Meta = { + component: FeaturesAttributesDependencies, + // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/7.0/react/writing-docs/docs-page + // tags: ["autodocs"], + parameters: { + // More on Story layout: https://storybook.js.org/docs/react/configure/story-layout + layout: "centered", + }, + // More on argTypes: https://storybook.js.org/docs/react/api/argtypes + argTypes: {}, + args: { + onUpdate: fn(), + }, +}; + +const sampleFeaturesAttributes: FeatureAttributesIndex = { + age: { + type: "continuous", + data_type: "number", + decimal_places: 0, + sample: "23.3", + bounds: { + min: 20, + max: 148, + allow_null: false, + }, + }, + sex: { + type: "nominal", + data_type: "number", + decimal_places: 0, + sample: "1", + bounds: { + allow_null: false, + }, + }, + country: { + type: "nominal", + data_type: "string", + sample: "The United Kingdom of Great Britain and Northern Ireland", + bounds: { + allow_null: true, + }, + }, + measurements: { + type: "nominal", + data_type: "json", + sample: `{ + height: "6\\"2'", + weight: "174 lbs" + }`, + bounds: { + allow_null: true, + }, + }, + organDonor: { + type: "nominal", + data_type: "boolean", + sample: "true", + bounds: { + allow_null: true, + }, + }, + lastExam: { + type: "nominal", + data_type: "formatted_date_time", + date_time_format: "YYYY-MM-DD", + sample: null, + bounds: { + allow_null: true, + }, + }, + cp: { + type: "nominal", + data_type: "number", + decimal_places: 0, + sample: "2", + bounds: { + allow_null: false, + }, + }, + trestbps: { + type: "continuous", + // data_type: "number", Leaving this out of purpose to get the invalid configuration state + decimal_places: 0, + sample: "321.54", + bounds: { + min: 55, + max: 403, + allow_null: false, + }, + }, + + ca: { + type: "continuous", + data_type: "number", + decimal_places: 0, + sample: "5.3", + bounds: { + min: 0, + max: 7, + }, + }, + thal: { + type: "continuous", + data_type: "number", + decimal_places: 0, + sample: "4.53", + bounds: { + min: 3, + max: 7, + }, + }, + class: { + type: "nominal", + data_type: "number", + decimal_places: 0, + sample: "1", + bounds: { + allow_null: false, + }, + }, +}; + +export default meta; +type Story = StoryObj; + +// More on component templates: https://storybook.js.org/docs/react/writing-stories/introduction#using-args +export const Default: Story = { + // More on args: https://storybook.js.org/docs/react/writing-stories/args + args: { + featureAttributesIndexAtom: getFeaturesAttributesIndexAtom( + sampleFeaturesAttributes, + ), + }, +}; + +export const NoFeatures: Story = { + args: { + featureAttributesIndexAtom: getFeaturesAttributesIndexAtom({}), + }, +}; diff --git a/src/components/FeatureAttributes/FeaturesAttributesDependencies/FeaturesAttributesDependencies.tsx b/src/components/FeatureAttributes/FeaturesAttributesDependencies/FeaturesAttributesDependencies.tsx new file mode 100644 index 0000000..7e832ad --- /dev/null +++ b/src/components/FeatureAttributes/FeaturesAttributesDependencies/FeaturesAttributesDependencies.tsx @@ -0,0 +1,189 @@ +import { type FC, useState, type MouseEvent } from "react"; +import { useAtom } from "jotai"; +import { + Alert, + ButtonProps, + Checkbox, + HelperText, + Table, + Tooltip, +} from "flowbite-react"; +import { + PrimaryButton, + ReadabilityConstraint, + TableHeadCell, + UpdateIcon, + WarningIcon, +} from "@howso/react-tailwind-flowbite-components"; +import { useDefaultTranslation } from "@/hooks"; +import { FeatureAttributesIndex, FeatureAttributesIndexAtom } from "../utils"; + +export type FeaturesAttributesDependenciesProps = { + featureAttributesIndexAtom: FeatureAttributesIndexAtom; + /** A function to be called update operations */ + onUpdate?: (event: MouseEvent) => void; +}; +/** + * Provides a feature to feature grid allowing users to quickly associate features with each other. + * + * @see https://documentation.howso.com/en/latest/openapi/types/FeatureAttributes.html#howso.openapi.models.FeatureAttributes.dependent_features + */ +export const FeaturesAttributesDependencies: FC< + FeaturesAttributesDependenciesProps +> = (props) => { + const { t } = useDefaultTranslation(); + const [featuresAttributes, setFeaturesAttributes] = useAtom( + props.featureAttributesIndexAtom, + ); + const [dependencies, setDependencies] = useState( + getDependencies(featuresAttributes), + ); + const features = Object.keys(featuresAttributes); + + const onUpdate: ButtonProps["onClick"] = (event) => { + event.preventDefault(); + setFeaturesAttributes((featureAttributes) => { + const updates = { ...featureAttributes }; + // Remove all current dependencies + Object.keys(updates).forEach((feature) => { + delete updates[feature].dependent_features; + }); + + // Loop through the dependencies map updating features + Object.entries(dependencies).forEach(([key, value]) => { + if (!value) { + return; + } + const [featureA, featureB] = key.split(":"); + if (featureA === featureB) { + return; + } + + updates[featureA].dependent_features ||= []; + updates[featureB].dependent_features ||= []; + updates[featureA].dependent_features!.push(featureB); + updates[featureB].dependent_features!.push(featureA); + }); + + return updates; + }); + + props.onUpdate && props.onUpdate(event); + }; + + return ( + <> + + + {t("FeatureAttributes.FeaturesAttributesDependencies.help")} + + + {features.length > 0 ? ( + <> +
+ + + + {features.map((feature) => ( + +
+ {feature} +
+
+ ))} +
+ + {features.map((featureA, indexA) => ( + + +
{featureA}
+
+ {features.map((featureB, indexB) => { + const key: DependenciesIndexKey = + indexB < indexA + ? `${featureB}:${featureA}` + : `${featureA}:${featureB}`; + return ( + + {featureA !== featureB && ( + + { + setDependencies((dependencies) => ({ + ...dependencies, + [key]: event.target.checked, + })); + }} + color={"blue"} + checked={dependencies[key]} + data-feature-a={featureA} + data-feature-b={featureB} + /> + + )} + + ); + })} +
+ ))} +
+
+
+
+ + + + {t( + "FeatureAttributes.FeaturesAttributesDependencies.actions.update", + )} + + +
+ + ) : ( + + {t("FeatureAttributes.FeaturesAttributesDependencies.state.empty")} + + )} + + ); +}; + +type DependenciesIndexKey = `${string}:${string}`; +type DependenciesIndex = Record; +const getDependencies = ( + featureAttributesIndex: FeatureAttributesIndex, +): DependenciesIndex => { + const features = Object.keys(featureAttributesIndex); + return features.reduce((dependencies, featureA) => { + features.forEach((featureB) => { + const key: DependenciesIndexKey = `${featureA}:${featureB}`; + if (dependencies[key]) { + return; + } + + dependencies[key] = + !!featureAttributesIndex[featureA].dependent_features?.includes( + featureB, + ) || + !!featureAttributesIndex[featureB].dependent_features?.includes( + featureA, + ); + }); + return dependencies; + }, {} as DependenciesIndex); +}; diff --git a/src/components/FeatureAttributes/FeaturesAttributesDependencies/index.ts b/src/components/FeatureAttributes/FeaturesAttributesDependencies/index.ts new file mode 100644 index 0000000..84a87a4 --- /dev/null +++ b/src/components/FeatureAttributes/FeaturesAttributesDependencies/index.ts @@ -0,0 +1 @@ +export * from "./FeaturesAttributesDependencies"; diff --git a/src/components/FeatureAttributes/FeaturesAttributesForms/FeaturesAttributesForms.stories.tsx b/src/components/FeatureAttributes/FeaturesAttributesForms/FeaturesAttributesForms.stories.tsx new file mode 100644 index 0000000..6f6e1f7 --- /dev/null +++ b/src/components/FeatureAttributes/FeaturesAttributesForms/FeaturesAttributesForms.stories.tsx @@ -0,0 +1,251 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { FeaturesAttributesForms } from "./FeaturesAttributesForms"; +import { FeatureAttributes } from "@howso/openapi-client"; +import { + FeatureAttributesIndex, + getFeaturesDirtyAtom, + getActiveFeatureAtom, + getFeaturesAttributesIndexAtom, + getFeaturesOptionsAtom, + getSetFeatureAttributesAtom, + getTimeFeatureAtom, +} from "../utils"; + +// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction#default-export +const meta: Meta = { + component: FeaturesAttributesForms, + // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/7.0/react/writing-docs/docs-page + // tags: ["autodocs"], + parameters: { + // More on Story layout: https://storybook.js.org/docs/react/configure/story-layout + layout: "centered", + }, + // More on argTypes: https://storybook.js.org/docs/react/api/argtypes + argTypes: {}, + args: {}, +}; + +const sampleFeaturesAttributes: FeatureAttributesIndex = { + age: { + type: "continuous", + data_type: "number", + decimal_places: 0, + sample: "23.3", + bounds: { + min: 20, + max: 148, + allow_null: false, + }, + }, + sex: { + type: "nominal", + data_type: "number", + decimal_places: 0, + sample: "1", + bounds: { + allow_null: false, + }, + }, + country: { + type: "nominal", + data_type: "string", + sample: "The United Kingdom of Great Britain and Northern Ireland", + bounds: { + allow_null: true, + }, + }, + measurements: { + type: "nominal", + data_type: "json", + sample: `{ + height: "6\\"2'", + weight: "174 lbs" + }`, + bounds: { + allow_null: true, + }, + }, + organDonor: { + type: "nominal", + data_type: "boolean", + sample: "true", + bounds: { + allow_null: true, + }, + }, + lastExam: { + type: "nominal", + data_type: "formatted_date_time", + date_time_format: "YYYY-MM-DD", + sample: null, + bounds: { + allow_null: true, + }, + }, + cp: { + type: "nominal", + data_type: "number", + decimal_places: 0, + sample: "2", + bounds: { + allow_null: false, + }, + }, + trestbps: { + type: "continuous", + // data_type: "number", Leaving this out of purpose to get the invalid configuration state + decimal_places: 0, + sample: "321.54", + bounds: { + min: 55, + max: 403, + allow_null: false, + }, + }, + + ca: { + type: "continuous", + data_type: "number", + decimal_places: 0, + sample: "5.3", + bounds: { + min: 0, + max: 7, + }, + }, + thal: { + type: "continuous", + data_type: "number", + decimal_places: 0, + sample: "4.53", + bounds: { + min: 3, + max: 7, + }, + }, + class: { + type: "nominal", + data_type: "number", + decimal_places: 0, + sample: "1", + bounds: { + allow_null: false, + }, + }, +}; +const timeFeature: FeatureAttributes = { + type: "continuous", + data_type: "string", + date_time_format: "", + sample: "2024-01-02T14:23:343.002", + time_series: { + time_feature: true, + type: "rate", + }, +}; + +export default meta; +type Story = StoryObj; + +// More on component templates: https://storybook.js.org/docs/react/writing-stories/introduction#using-args +const defaultDirtyAtom = getFeaturesDirtyAtom(); +export const Default: Story = { + // More on args: https://storybook.js.org/docs/react/writing-stories/args + args: { + activeFeatureAtom: getActiveFeatureAtom(), + featureAttributesIndexAtom: getFeaturesAttributesIndexAtom( + sampleFeaturesAttributes, + ), + optionsAtom: getFeaturesOptionsAtom({}), + }, +}; +Default.args!.setFeatureAttributesAtom = getSetFeatureAttributesAtom({ + featureAttributesIndexAtom: Default.args!.featureAttributesIndexAtom!, + featuresDirtyAtom: defaultDirtyAtom, +}); +Default.args!.timeFeatureAtom = getTimeFeatureAtom({ + featureAttributesIndexAtom: Default.args!.featureAttributesIndexAtom!, + featuresDirtyAtom: defaultDirtyAtom, +}); + +const noFeaturesDirtyAtom = getFeaturesDirtyAtom(); +export const NoFeatures: Story = { + args: { + activeFeatureAtom: getActiveFeatureAtom(), + featureAttributesIndexAtom: getFeaturesAttributesIndexAtom({}), + optionsAtom: getFeaturesOptionsAtom({}), + }, +}; +NoFeatures.args!.setFeatureAttributesAtom = getSetFeatureAttributesAtom({ + featureAttributesIndexAtom: NoFeatures.args!.featureAttributesIndexAtom!, + featuresDirtyAtom: noFeaturesDirtyAtom, +}); +NoFeatures.args!.timeFeatureAtom = getTimeFeatureAtom({ + featureAttributesIndexAtom: NoFeatures.args!.featureAttributesIndexAtom!, + featuresDirtyAtom: noFeaturesDirtyAtom, +}); + +const configurationDirtyAtom = getFeaturesDirtyAtom(); +export const Configuration: Story = { + args: { + activeFeatureAtom: getActiveFeatureAtom( + Object.keys(sampleFeaturesAttributes).shift(), + ), + featureAttributesIndexAtom: getFeaturesAttributesIndexAtom( + sampleFeaturesAttributes, + ), + optionsAtom: getFeaturesOptionsAtom({}), + }, +}; +Configuration.args!.setFeatureAttributesAtom = getSetFeatureAttributesAtom({ + featureAttributesIndexAtom: Configuration.args!.featureAttributesIndexAtom!, + featuresDirtyAtom: configurationDirtyAtom, +}); +Configuration.args!.timeFeatureAtom = getTimeFeatureAtom({ + featureAttributesIndexAtom: Configuration.args!.featureAttributesIndexAtom!, + featuresDirtyAtom: configurationDirtyAtom, +}); + +const configurationLastItemDirtyAtom = getFeaturesDirtyAtom(); +export const ConfigurationLastItem: Story = { + args: { + activeFeatureAtom: getActiveFeatureAtom( + Object.keys(sampleFeaturesAttributes).pop(), + ), + featureAttributesIndexAtom: getFeaturesAttributesIndexAtom( + sampleFeaturesAttributes, + ), + optionsAtom: getFeaturesOptionsAtom({}), + }, +}; +ConfigurationLastItem.args!.setFeatureAttributesAtom = + getSetFeatureAttributesAtom({ + featureAttributesIndexAtom: + ConfigurationLastItem.args!.featureAttributesIndexAtom!, + featuresDirtyAtom: configurationLastItemDirtyAtom, + }); +ConfigurationLastItem.args!.timeFeatureAtom = getTimeFeatureAtom({ + featureAttributesIndexAtom: + ConfigurationLastItem.args!.featureAttributesIndexAtom!, + featuresDirtyAtom: configurationLastItemDirtyAtom, +}); + +const timeSeriesDirtyAtom = getFeaturesDirtyAtom(); +export const TimeSeries: Story = { + args: { + activeFeatureAtom: getActiveFeatureAtom(), + featureAttributesIndexAtom: getFeaturesAttributesIndexAtom({ + timeFeature, + ...sampleFeaturesAttributes, + }), + optionsAtom: getFeaturesOptionsAtom({ time_series: true }), + }, +}; +TimeSeries.args!.setFeatureAttributesAtom = getSetFeatureAttributesAtom({ + featureAttributesIndexAtom: TimeSeries.args!.featureAttributesIndexAtom!, + featuresDirtyAtom: timeSeriesDirtyAtom, +}); +TimeSeries.args!.timeFeatureAtom = getTimeFeatureAtom({ + featureAttributesIndexAtom: TimeSeries.args!.featureAttributesIndexAtom!, + featuresDirtyAtom: timeSeriesDirtyAtom, +}); diff --git a/src/components/FeatureAttributes/FeaturesAttributesForms/FeaturesAttributesForms.test.tsx b/src/components/FeatureAttributes/FeaturesAttributesForms/FeaturesAttributesForms.test.tsx new file mode 100644 index 0000000..34ac057 --- /dev/null +++ b/src/components/FeatureAttributes/FeaturesAttributesForms/FeaturesAttributesForms.test.tsx @@ -0,0 +1,202 @@ +import { + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { FeaturesAttributesForms } from "./FeaturesAttributesForms"; +import { FeatureAttributes } from "@howso/openapi-client"; +import { + FeatureAttributeFormValues, + getFeatureAttributesFromFormData, +} from "./utils"; +import { + FeatureAttributesIndex, + getFeaturesDirtyAtom, + getFeaturesAttributesIndexAtom, + getSetFeatureAttributesAtom, + getTimeFeatureAtom, + getActiveFeatureAtom, + getFeaturesOptionsAtom, +} from "../utils"; +import { getAllowedValuesFieldInElement } from "../fields/FeatureAttributeAllowedValuesField/FeatureAttributeAllowedValuesField.test"; +import { getDataTypeFieldInElement } from "../fields/FeatureAttributeDataTypeField/FeatureAttributeDataTypeField.test"; +import { getFeatureTypeFieldInElement } from "../fields/FeatureAttributeTypeField/FeatureAttributeTypeField.test"; +import { featuresAttributesFormsTranslations } from "./constants"; + +describe("FeaturesFields", () => { + it("should open a configuration modal, save, and load the next", async () => { + const featuresAttributes: FeatureAttributesIndex = { + age: { + type: "continuous", + data_type: "number", + decimal_places: 0, + sample: "22", + bounds: { + min: 20, + max: 148, + allow_null: false, + }, + }, + sex: { + type: "nominal", + data_type: "string", + non_sensitive: true, + sample: "male", + bounds: { + allowed: ["male", "female", "twin soul"], + allow_null: false, + }, + }, + location: { + type: "nominal", + data_type: "string", + subtype: "address", + non_sensitive: false, + sample: + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", + bounds: { + allow_null: true, + }, + }, + }; + const featureEntries = Object.entries(featuresAttributes); + + const featuresDirtyAtom = getFeaturesDirtyAtom(); + const featureAttributesIndexAtom = + getFeaturesAttributesIndexAtom(featuresAttributes); + const setFeatureAttributesAtom = getSetFeatureAttributesAtom({ + featureAttributesIndexAtom, + featuresDirtyAtom, + }); + const timeFeatureAtom = getTimeFeatureAtom({ + featureAttributesIndexAtom, + featuresDirtyAtom, + }); + + render( + , + ); + + const rows = screen.getAllByRole("row").slice(1); + expect(rows.length).toBe(featureEntries.length); + + const configure = within(rows[0]).getByRole("button", { + name: new RegExp( + `.*${featuresAttributesFormsTranslations.actions.configure}.*`, + ), + }); + fireEvent( + configure, + new MouseEvent("click", { bubbles: true, cancelable: true }), + ); + + for (let i = 0; i <= featureEntries.length - 1; i++) { + const [feature, attributes] = featureEntries[i]; + + const modal = screen.getByRole("dialog"); + await expectFeatureAttributesInDialog(modal, feature, attributes); + if (i <= featureEntries.length - 2) { + const updateAndNext = within(modal).getByRole("button", { + name: new RegExp( + `.*${featuresAttributesFormsTranslations.actions.updateAndGoToTarget}.*`, + ), + }); + fireEvent( + updateAndNext, + new MouseEvent("click", { bubbles: true, cancelable: true }), + ); + } + } + }); +}); + +const expectFeatureAttributesInDialog = async ( + modal: HTMLElement, + feature: string, + attributes: FeatureAttributes, +) => { + expect(modal).toBeVisible(); + expect(within(modal).getByRole("heading")).toHaveTextContent( + "actions.configure_{{name}}", + ); + await waitFor(() => { + expect(within(modal).getByRole("form").dataset.feature).toBe(feature); + }); + expect(getFeatureTypeFieldInElement(modal)).toHaveValue( + attributes.type || "", + ); + expect(getDataTypeFieldInElement(modal)).toHaveValue( + attributes.data_type || "", + ); + attributes.bounds?.allowed?.forEach((element) => { + expect( + getAllowedValuesFieldInElement(modal, attributes.type), + ).toHaveTextContent(element); + }); +}; + +describe("getFeatureAttributesFromFormData", () => { + it("should remove empty values", () => { + const data: FeatureAttributeFormValues = { + type: "continuous", + data_type: "number", + is_datetime: false, + significant_digits: undefined, + decimal_places: undefined, + date_time_format: "", + }; + const attributes = getFeatureAttributesFromFormData(data); + const toBeRemoved: (keyof FeatureAttributes)[] = [ + "significant_digits", + "decimal_places", + "date_time_format", + ]; + toBeRemoved.forEach((property) => { + expect(attributes).not.toHaveProperty(property); + }); + }); + + it("should not remove 0's", () => { + const data: FeatureAttributeFormValues = { + type: "continuous", + data_type: "number", + is_datetime: false, + significant_digits: 0, + decimal_places: 0, + }; + const attributes = getFeatureAttributesFromFormData(data); + const toBePreserve: (keyof FeatureAttributes)[] = [ + "significant_digits", + "decimal_places", + ]; + toBePreserve.forEach((property) => { + expect(attributes[property]).toBe(0); + }); + }); + + it("should trim strings", () => { + const data: FeatureAttributeFormValues = { + type: "continuous", + data_type: "string", + is_datetime: true, + date_time_format: "YYYY-MM-DD ", + bounds: { + allowed: ["This little ", "piggy went to ", "market "], + }, + }; + const attributes = getFeatureAttributesFromFormData(data); + expect(attributes.date_time_format).toBe(data.date_time_format?.trim()); + expect(attributes.bounds?.allowed?.join(" ")).toBe( + data.bounds?.allowed?.map((value) => value.trim())?.join(" "), + ); + }); +}); diff --git a/src/components/FeatureAttributes/FeaturesAttributesForms/FeaturesAttributesForms.tsx b/src/components/FeatureAttributes/FeaturesAttributesForms/FeaturesAttributesForms.tsx new file mode 100644 index 0000000..4bcb3ac --- /dev/null +++ b/src/components/FeatureAttributes/FeaturesAttributesForms/FeaturesAttributesForms.tsx @@ -0,0 +1,453 @@ +import { ChangeEvent, FC, useCallback, useEffect, useState } from "react"; +import { FeatureAttributes } from "@howso/openapi-client"; +import { Table, Button, Radio, Modal, Alert, getTheme } from "flowbite-react"; +import { FormProvider, SubmitHandler, useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { FeatureAttributeSample } from "../FeatureAttributeSample"; +import { FeatureAttributesConfiguration } from "../FeatureAttributesConfiguration"; +import { + FeatureAttributeTypeField, + featureAttributeTypeLabel, +} from "../fields"; +import { useAtom, useAtomValue, useSetAtom } from "jotai/react"; +import { twMerge } from "tailwind-merge"; +import { + FeatureAttributeFormValues, + getFeatureAttributesFromFormData, +} from "./utils"; +import { FeaturesAttributesDependencies } from "../FeaturesAttributesDependencies"; +import { useDefaultTranslation } from "@/hooks"; +import { + ErrorBoundary, + FormModal, + ToggleInput, + UpdateIcon, + WarningIcon, +} from "@howso/react-tailwind-flowbite-components"; +import { + ActiveFeatureAtom, + FeatureAttributesIndexAtom, + SetFeatureAttributesAtom, + FeatureOptionsAtom, + TimeFeatureAtom, + areFeatureAttributesValid, + getFeatureAttributesForType, +} from "../utils"; +import { MapDependentFeatureAttributesIcon } from "@/components/Icons"; +import { featuresAttributesFormsTranslations } from "./constants"; + +export type FeaturesAttributesFormsProps = { + activeFeatureAtom: ActiveFeatureAtom; + featureAttributesIndexAtom: FeatureAttributesIndexAtom; + setFeatureAttributesAtom: SetFeatureAttributesAtom; + optionsAtom: FeatureOptionsAtom; + timeFeatureAtom: TimeFeatureAtom; +}; +export const FeaturesAttributesForms: FC = ( + props, +) => { + const { t } = useDefaultTranslation(); + const { + activeFeatureAtom, + featureAttributesIndexAtom, + optionsAtom, + timeFeatureAtom, + } = props; + const activeFeature = useAtomValue(activeFeatureAtom); + const featuresAttributes = useAtomValue(featureAttributesIndexAtom); + const [options, setOptions] = useAtom(optionsAtom); + const setTimeFeature = useSetAtom(timeFeatureAtom); + + const features = Object.keys(featuresAttributes); + + // Toggle time series + const onChangeTimeSeries = (evt: ChangeEvent) => { + setOptions({ ...options, time_series: evt.currentTarget.checked }); + if (!evt.currentTarget.checked) setTimeFeature(null); + }; + + return ( + <> + +
+ + + + {t( + "FeatureAttributes.FeaturesAttributesForms.headings.feature", + )} + + + {t("FeatureAttributes.FeaturesAttributesForms.headings.sample")} + + + {t(featureAttributeTypeLabel)} + + +
+ {options.time_series + ? t( + "FeatureAttributes.FeaturesAttributesForms.headings.timeFeature", + ) + : t( + "FeatureAttributes.FeaturesAttributesForms.headings.timeSeries", + )} + +
+
+ + {t( + "FeatureAttributes.FeaturesAttributesForms.headings.configuration", + )} + +
+ + {features.map((featureName) => ( + + ))} + +
+
+ {!features.length && ( + + {t("FeatureAttributes.FeaturesAttributesForms.state.empty")} + + )} + +
+ {activeFeature && } + + ); +}; + +type FeatureFieldsProps = { + feature: string; +} & Pick< + FeaturesAttributesFormsProps, + | "activeFeatureAtom" + | "featureAttributesIndexAtom" + | "setFeatureAttributesAtom" + | "optionsAtom" + | "timeFeatureAtom" +>; +const FeatureFields: FC = ({ + activeFeatureAtom, + featureAttributesIndexAtom, + feature, + setFeatureAttributesAtom, + optionsAtom, + timeFeatureAtom, +}) => { + const { t } = useDefaultTranslation(); + const theme = getTheme(); + const setActiveFeature = useSetAtom(activeFeatureAtom); + const featuresAttributes = useAtomValue(featureAttributesIndexAtom); + const options = useAtomValue(optionsAtom); + const setFeatureAttributes = useSetAtom(setFeatureAttributesAtom); + + const attributes = featuresAttributes[feature]; + const setFeatureType = useCallback( + (attributes: FeatureAttributes) => { + setFeatureAttributes(feature, attributes, { type: true }); + }, + [feature, setFeatureAttributes], + ); + const isValid = areFeatureAttributesValid(attributes); + + return ( + + + {feature} + + +
+ +
+
+ + + + + {options.time_series && ( + + )} + + +
+ + + {!isValid && ( + + )} +
+
+
+ ); +}; + +type InlineInputProps = { + feature: string; +}; +type AttributeProps = { + attributes: FeatureAttributes; + setAttributes: (attributes: FeatureAttributes) => void; +}; + +type FeatureTypeControlProps = InlineInputProps & AttributeProps; +// Table row feature type dropdown +const FeatureTypeControl: FC = ({ + attributes, + setAttributes, +}) => { + const form = useForm({ defaultValues: attributes }); + + const onChange = useCallback(async () => { + const values = form.getValues(); + setAttributes(values); + }, [form, setAttributes]); + + return ( + + + + ); +}; + +// Table row time feature radio +type TimeFeatureControlProps = InlineInputProps & + Pick & + Pick & { disabled?: boolean }; +const TimeFeatureControl: FC = ({ + attributes, + disabled, + feature, + timeFeatureAtom, +}) => { + const [timeFeature, setTimeFeature] = useAtom(timeFeatureAtom); + useEffect(() => { + // Clear time feature when switched to non-continuous type + if (feature === timeFeature?.name && attributes?.type !== "continuous") + setTimeFeature(null); + }, [attributes?.type, timeFeature, setTimeFeature, feature]); + + if (attributes?.type !== "continuous") return null; + + return ( + { + setTimeFeature(feature); + }} + checked={attributes?.time_series?.time_feature ?? false} + disabled={disabled} + /> + ); +}; + +type ConfigureModalProps = Pick< + FeaturesAttributesFormsProps, + | "activeFeatureAtom" + | "featureAttributesIndexAtom" + | "setFeatureAttributesAtom" + | "timeFeatureAtom" +>; +const ConfigureModal: FC = (props) => { + const [activeFeature, setActiveFeature] = useAtom(props.activeFeatureAtom); + const onClose = useCallback(() => setActiveFeature(null), [setActiveFeature]); + + return ( + + {/* Purpose using `key` here to force the component to load and unload, creating new `useForm` defaults */} +
+ + ); +}; + +const Form: FC void }> = ({ + featureAttributesIndexAtom, + activeFeatureAtom, + setFeatureAttributesAtom, + timeFeatureAtom, + onClose, +}) => { + const { t } = useTranslation(); + const [activeFeature, setActiveFeature] = useAtom(activeFeatureAtom); + if (!activeFeature) { + throw new Error("activeFeature is undefined"); + } + const featuresAttributes = useAtomValue(featureAttributesIndexAtom); + const attributes = featuresAttributes[activeFeature]; + const setFeatureAttributes = useSetAtom(setFeatureAttributesAtom); + const timeFeature = useAtomValue(timeFeatureAtom); + + const form = useForm({ + defaultValues: { + ...getFeatureAttributesForType(attributes), + is_datetime: !!attributes.date_time_format, + }, + shouldUnregister: true, + }); + + const { dirtyFields } = form.formState; + const features = Object.keys(featuresAttributes); + const nextFeature = features[features.indexOf(activeFeature) + 1]; + + const onSave: SubmitHandler = (data) => { + save(data); + onClose(); + }; + const onSaveAndContinue: SubmitHandler = ( + data, + ) => { + save(data); + setActiveFeature(nextFeature); + }; + + const save: SubmitHandler = (data) => { + const attributes = getFeatureAttributesFromFormData(data); + setFeatureAttributes(activeFeature, attributes, dirtyFields); + }; + + return ( + + + + {t(featuresAttributesFormsTranslations.actions.configureName, { + name: activeFeature, + })} + + + + + + + +
+ + {nextFeature && ( + + )} +
+
+ +
+ ); +}; + +type ControlsProps = Pick< + FeaturesAttributesFormsProps, + "featureAttributesIndexAtom" +> & { + containerProps: React.HTMLProps; +}; +const Controls: FC = ({ + featureAttributesIndexAtom, + containerProps, +}) => { + return ( +
+ +
+ ); +}; + +type MapDependenciesControlProps = Pick< + FeaturesAttributesFormsProps, + "featureAttributesIndexAtom" +>; +const MapDependenciesControl: FC = (props) => { + const { t } = useDefaultTranslation(); + const [isOpen, setIsOpen] = useState(false); + const onOpen = () => { + setIsOpen(true); + }; + const onClose = () => { + setIsOpen(false); + }; + + const label = t(featuresAttributesFormsTranslations.actions.mapDependents); + return ( + <> + + {isOpen && ( + +
+ {label} + + + + + +
+
+ )} + + ); +}; diff --git a/src/components/FeatureAttributes/FeaturesAttributesForms/constants.ts b/src/components/FeatureAttributes/FeaturesAttributesForms/constants.ts new file mode 100644 index 0000000..51b8a5b --- /dev/null +++ b/src/components/FeatureAttributes/FeaturesAttributesForms/constants.ts @@ -0,0 +1,15 @@ +export const featuresAttributesFormsTranslations = { + form: { + label: "FeatureAttributes.FeaturesAttributesForms.form.label", + }, + actions: { + configure: "FeatureAttributes.FeaturesAttributesForms.actions.configure", + configureName: + "FeatureAttributes.FeaturesAttributesForms.actions.configure_{{name}}", + mapDependents: + "FeatureAttributes.FeaturesAttributesForms.actions.mapDependents", + update: "FeatureAttributes.FeaturesAttributesForms.actions.update", + updateAndGoToTarget: + "FeatureAttributes.FeaturesAttributesForms.actions.updateAndGoTo_{{target}}", + }, +}; diff --git a/src/components/FeatureAttributes/FeaturesAttributesForms/index.ts b/src/components/FeatureAttributes/FeaturesAttributesForms/index.ts new file mode 100644 index 0000000..817d76a --- /dev/null +++ b/src/components/FeatureAttributes/FeaturesAttributesForms/index.ts @@ -0,0 +1 @@ +export * from "./FeaturesAttributesForms"; diff --git a/src/components/FeatureAttributes/FeaturesAttributesForms/utils.ts b/src/components/FeatureAttributes/FeaturesAttributesForms/utils.ts new file mode 100644 index 0000000..e039d15 --- /dev/null +++ b/src/components/FeatureAttributes/FeaturesAttributesForms/utils.ts @@ -0,0 +1,93 @@ +import { isNull } from "@/utils"; +import { FeatureAttributes } from "@howso/openapi-client"; + +export type FeatureAttributeFormValues = FeatureAttributes & { + is_datetime: boolean; +}; + +/** + * Reshapes form data, which has empty strings and arrays, etc + * into the feature attributes format by removing those empty values. + */ +export const getFeatureAttributesFromFormData = ( + data: FeatureAttributeFormValues, +): FeatureAttributes => { + const attributes = Object.entries(data).reduce((attributes, [key, value]) => { + const skipKeys: (keyof FeatureAttributeFormValues)[] = [ + "is_datetime", + "sample", + ]; + const keyAs = key as keyof FeatureAttributeFormValues; + if (skipKeys.includes(keyAs)) { + return attributes; + } + if (isFormDataEmpty(value)) { + return attributes; + } + // @ts-expect-error Shoosh + attributes[keyAs] = sanitizeFeatureAttributeValue(value); + return attributes; + }, {} as FeatureAttributes); + + if (attributes.time_series) { + attributes.time_series = Object.entries(attributes.time_series).reduce( + (attributes, [key, value]) => { + if (isFormDataEmpty(value)) { + return attributes; + } + + // @ts-expect-error Shoosh + attributes[key] = sanitizeFeatureAttributeValue(value); + return attributes; + }, + {} as FeatureAttributes["time_series"], + ); + } + + if (attributes.bounds) { + attributes.bounds = Object.entries(attributes.bounds).reduce( + (attributes, [key, value]) => { + if (isFormDataEmpty(value)) { + return attributes; + } + + // @ts-expect-error Shoosh + attributes[key] = sanitizeFeatureAttributeValue(value); + return attributes; + }, + {} as FeatureAttributes["bounds"], + ); + } + + if (!attributes.id_feature || attributes.type === "continuous") + attributes.id_feature = undefined; + return attributes; +}; + +const isFormDataEmpty = (value: unknown): boolean => { + if (value === "") { + return true; + } + + if (isNull(value)) { + return true; + } + + if (Array.isArray(value) && !value.length) { + return true; + } + + return false; +}; + +const sanitizeFeatureAttributeValue = (value: unknown): unknown => { + if (typeof value === "string") { + return value.trim(); + } + + if (Array.isArray(value)) { + return value.map(sanitizeFeatureAttributeValue); + } + + return value; +}; diff --git a/src/components/FeatureAttributes/fields/FeatureAttributeAllowNullsField/FeatureAttributeAllowNullsField.stories.tsx b/src/components/FeatureAttributes/fields/FeatureAttributeAllowNullsField/FeatureAttributeAllowNullsField.stories.tsx new file mode 100644 index 0000000..a2930b5 --- /dev/null +++ b/src/components/FeatureAttributes/fields/FeatureAttributeAllowNullsField/FeatureAttributeAllowNullsField.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { FeatureAttributeAllowNullsField } from "./FeatureAttributeAllowNullsField"; +import { getFormProviderDecorator } from "@/storybook"; + +// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction#default-export +const meta: Meta = { + component: FeatureAttributeAllowNullsField, + // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/7.0/react/writing-docs/docs-page + tags: ["autodocs"], + parameters: { + // More on Story layout: https://storybook.js.org/docs/react/configure/story-layout + layout: "centered", + }, + decorators: [getFormProviderDecorator()], + // More on argTypes: https://storybook.js.org/docs/react/api/argtypes + argTypes: {}, + args: {}, +}; + +export default meta; +type Story = StoryObj; + +// More on component templates: https://storybook.js.org/docs/react/writing-stories/introduction#using-args +export const Default: Story = { + // More on args: https://storybook.js.org/docs/react/writing-stories/args + args: {}, +}; diff --git a/src/components/FeatureAttributes/fields/FeatureAttributeAllowNullsField/FeatureAttributeAllowNullsField.test.tsx b/src/components/FeatureAttributes/fields/FeatureAttributeAllowNullsField/FeatureAttributeAllowNullsField.test.tsx new file mode 100644 index 0000000..69b7fba --- /dev/null +++ b/src/components/FeatureAttributes/fields/FeatureAttributeAllowNullsField/FeatureAttributeAllowNullsField.test.tsx @@ -0,0 +1,68 @@ +import { render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { FeatureAttributeAllowNullsField } from "./FeatureAttributeAllowNullsField"; +import { + featureAttributeAllowNullsFieldName, + featureAttributeAllowNullsFieldLabel, +} from "./constants"; +import { useForm, FormProvider, UseFormProps } from "react-hook-form"; +import { FC, ReactNode } from "react"; + +describe("AllowNullsField", () => { + it("should be rendered with a default value of true if not in form context", async () => { + render( + + + , + ); + + const field = getField(); + expect(field).toBeTruthy(); + expect(field).toBeChecked(); + }); + + it("should be rendered checked if checked in form context", async () => { + render( + + + , + ); + + const field = getField(); + expect(field).toBeTruthy(); + expect(field).toBeChecked(); + }); + + it("should be rendered unchecked if unchecked in form context", async () => { + render( + + + , + ); + + const field = getField(); + expect(field).toBeTruthy(); + expect(field).not.toBeChecked(); + }); +}); + +const Wrapper: FC<{ children: ReactNode; formProps?: UseFormProps }> = ({ + children, + formProps, +}) => { + const form = useForm(formProps); + return {children}; +}; + +const getField = () => + screen.queryByLabelText( + new RegExp(`^${featureAttributeAllowNullsFieldLabel}.*`), + ); diff --git a/src/components/FeatureAttributes/fields/FeatureAttributeAllowNullsField/FeatureAttributeAllowNullsField.tsx b/src/components/FeatureAttributes/fields/FeatureAttributeAllowNullsField/FeatureAttributeAllowNullsField.tsx new file mode 100644 index 0000000..78f3e86 --- /dev/null +++ b/src/components/FeatureAttributes/fields/FeatureAttributeAllowNullsField/FeatureAttributeAllowNullsField.tsx @@ -0,0 +1,37 @@ +import { FC } from "react"; +import { Controller, useFormContext } from "react-hook-form"; +import { useDefaultTranslation } from "@/hooks"; +import { + featureAttributeAllowNullsFieldLabel, + featureAttributeAllowNullsFieldName, +} from "./constants"; +import { FieldCheckbox } from "@howso/react-tailwind-flowbite-components"; + +export type FeatureAttributeAllowNullsFieldProps = Record; +/** + * Allow nulls to be output, per their distribution in the data. Defaults to true. + * + * @see https://documentation.howso.com/en/latest/openapi/types/FeatureBounds.html#howso.openapi.models.FeatureBounds.allow_null + */ +export const FeatureAttributeAllowNullsField: FC< + FeatureAttributeAllowNullsFieldProps +> = () => { + const { t } = useDefaultTranslation(); + const form = useFormContext(); + + return ( + ( + // @ts-expect-error There's a mismatch here I can't solve for + + )} + /> + ); +}; diff --git a/src/components/FeatureAttributes/fields/FeatureAttributeAllowNullsField/constants.ts b/src/components/FeatureAttributes/fields/FeatureAttributeAllowNullsField/constants.ts new file mode 100644 index 0000000..371d8ab --- /dev/null +++ b/src/components/FeatureAttributes/fields/FeatureAttributeAllowNullsField/constants.ts @@ -0,0 +1,3 @@ +export const featureAttributeAllowNullsFieldLabel = + "FeatureAttributes.FeatureAttributeAllowNullsField.label"; +export const featureAttributeAllowNullsFieldName = "bounds.allow_null"; diff --git a/src/components/FeatureAttributes/fields/FeatureAttributeAllowNullsField/index.ts b/src/components/FeatureAttributes/fields/FeatureAttributeAllowNullsField/index.ts new file mode 100644 index 0000000..22c38e4 --- /dev/null +++ b/src/components/FeatureAttributes/fields/FeatureAttributeAllowNullsField/index.ts @@ -0,0 +1,2 @@ +export * from "./FeatureAttributeAllowNullsField"; +export * from "./constants"; diff --git a/src/components/FeatureAttributes/fields/FeatureAttributeAllowedValuesField/FeatureAttributeAllowedValuesField.stories.tsx b/src/components/FeatureAttributes/fields/FeatureAttributeAllowedValuesField/FeatureAttributeAllowedValuesField.stories.tsx new file mode 100644 index 0000000..a121253 --- /dev/null +++ b/src/components/FeatureAttributes/fields/FeatureAttributeAllowedValuesField/FeatureAttributeAllowedValuesField.stories.tsx @@ -0,0 +1,56 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { FeatureAttributeAllowedValuesField } from "./FeatureAttributeAllowedValuesField"; +import { getFormProviderDecorator } from "@/storybook"; +import { FC, ReactNode } from "react"; +import { UseFormProps, useForm, FormProvider } from "react-hook-form"; +import { FeatureAttributes } from "@howso/openapi-client"; + +const defaultValues: FeatureAttributes = { + type: "nominal", + data_type: "string", + bounds: { allowed: ["1234", "asdf"] }, +}; + +// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction#default-export +const meta: Meta = { + component: FeatureAttributeAllowedValuesField, + // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/7.0/react/writing-docs/docs-page + tags: ["autodocs"], + parameters: { + // More on Story layout: https://storybook.js.org/docs/react/configure/story-layout + layout: "centered", + }, + decorators: [getFormProviderDecorator()], + // More on argTypes: https://storybook.js.org/docs/react/api/argtypes + argTypes: {}, + args: { + featureType: defaultValues.type, + dataType: defaultValues.data_type, + }, +}; + +export default meta; +type Story = StoryObj; + +const Wrapper: FC<{ children: ReactNode; formProps?: UseFormProps }> = ({ + children, + formProps, +}) => { + const form = useForm(formProps); + return {children}; +}; + +// More on component templates: https://storybook.js.org/docs/react/writing-stories/introduction#using-args +export const Default: Story = { + // More on args: https://storybook.js.org/docs/react/writing-stories/args + args: {}, + render: () => ( + + + + ), +}; diff --git a/src/components/FeatureAttributes/fields/FeatureAttributeAllowedValuesField/FeatureAttributeAllowedValuesField.test.tsx b/src/components/FeatureAttributes/fields/FeatureAttributeAllowedValuesField/FeatureAttributeAllowedValuesField.test.tsx new file mode 100644 index 0000000..18eb998 --- /dev/null +++ b/src/components/FeatureAttributes/fields/FeatureAttributeAllowedValuesField/FeatureAttributeAllowedValuesField.test.tsx @@ -0,0 +1,121 @@ +import { within, screen, render } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { + featureAttributeAllowedValuesFieldNominalLabel, + featureAttributeAllowedValuesFieldOrdinalLabel, +} from "./constants"; +import { FeatureAttributes } from "@howso/openapi-client"; +import { FC, ReactNode } from "react"; +import { UseFormProps, useForm, FormProvider } from "react-hook-form"; +import { FeatureAttributeAllowedValuesField } from "./FeatureAttributeAllowedValuesField"; + +const nominalRegex = new RegExp( + `^${featureAttributeAllowedValuesFieldNominalLabel}.*`, +); +const ordinalRegex = new RegExp( + `^${featureAttributeAllowedValuesFieldOrdinalLabel}.*`, +); + +export const getAllowedValuesField = (type: FeatureAttributes["type"]) => + screen.getByLabelText(type === "nominal" ? nominalRegex : ordinalRegex); +export const getAllowedValuesFieldInElement = ( + element: HTMLElement, + type: FeatureAttributes["type"], +) => + within(element).getByLabelText( + type === "nominal" ? nominalRegex : ordinalRegex, + ); + +describe("FeatureAttributeAllowedValuesField", () => { + it("should use allowed values if nominal strings", async () => { + const defaultValues: FeatureAttributes = { + type: "nominal", + data_type: "string", + bounds: { allowed: ["1234", "asdf"] }, + }; + render( + + + , + ); + + expect(getAllowedValuesField(defaultValues.type)).toHaveValue( + defaultValues.bounds?.allowed?.join("\n"), + ); + }); + + it("should use allowed values if nominal numbers", async () => { + const defaultValues: FeatureAttributes = { + type: "nominal", + data_type: "number", + bounds: { allowed: [2, 3] }, + }; + render( + + + , + ); + + expect(getAllowedValuesField(defaultValues.type)).toHaveValue( + defaultValues.bounds?.allowed?.join("\n"), + ); + }); + + it("should use allowed values if ordinal strings", async () => { + const defaultValues: FeatureAttributes = { + type: "ordinal", + data_type: "string", + bounds: { allowed: ["1234", "asdf"] }, + }; + render( + + + , + ); + + expect(getAllowedValuesField(defaultValues.type)).toHaveValue( + defaultValues.bounds?.allowed?.join("\n"), + ); + }); + + it("should use allowed values if ordinal numbers", async () => { + const defaultValues: FeatureAttributes = { + type: "ordinal", + data_type: "number", + bounds: { allowed: [2, 3] }, + }; + render( + + + , + ); + + expect(getAllowedValuesField(defaultValues.type)).toHaveValue( + defaultValues.bounds?.allowed?.join("\n"), + ); + }); +}); + +const Wrapper: FC<{ children: ReactNode; formProps?: UseFormProps }> = ({ + children, + formProps, +}) => { + const form = useForm(formProps); + return {children}; +}; diff --git a/src/components/FeatureAttributes/fields/FeatureAttributeAllowedValuesField/FeatureAttributeAllowedValuesField.tsx b/src/components/FeatureAttributes/fields/FeatureAttributeAllowedValuesField/FeatureAttributeAllowedValuesField.tsx new file mode 100644 index 0000000..8755089 --- /dev/null +++ b/src/components/FeatureAttributes/fields/FeatureAttributeAllowedValuesField/FeatureAttributeAllowedValuesField.tsx @@ -0,0 +1,176 @@ +import { ChangeEventHandler, FC, forwardRef, useCallback } from "react"; +import { useDefaultTranslation } from "@/hooks"; +import { FeatureAttributes } from "@howso/openapi-client"; +import { + featureAttributeAllowedValuesFieldName, + featureAttributeAllowedValuesFieldNominalLabel, + featureAttributeAllowedValuesFieldOrdinalLabel, +} from "./constants"; +import { featureAttributeDateTimeFormatFieldPlaceholder } from "../FeatureAttributeDateTimeFormatField"; +import { FieldTextAreaList } from "@howso/react-tailwind-flowbite-components"; +import { Textarea, TextareaProps } from "flowbite-react"; +import { useFormContext } from "react-hook-form"; + +export type FeatureAttributeAllowedValuesFieldProps = { + featureType: FeatureAttributes["type"]; + dataType: FeatureAttributes["data_type"]; + dateTimeFormat: string | undefined; +}; +/** + * Explicitly allowed values to be output. + * + * @see https://documentation.howso.com/en/latest/openapi/types/FeatureBounds.html#howso.openapi.models.FeatureBounds.allowed + */ +export const FeatureAttributeAllowedValuesField: FC< + FeatureAttributeAllowedValuesFieldProps +> = ({ + featureType, + dataType, + dateTimeFormat = featureAttributeDateTimeFormatFieldPlaceholder, +}) => { + const { t } = useDefaultTranslation(); + const { control } = useFormContext(); + const allowedFeatureTypes: FeatureAttributes["type"][] = [ + "nominal", + "ordinal", + ]; + const allowedDataTypes: FeatureAttributes["data_type"][] = [ + "string", + "number", + "formatted_date_time", + ]; + if ( + !allowedFeatureTypes.includes(featureType) || + !allowedDataTypes.includes(dataType) + ) { + return null; + } + + const required = false; + const label = + featureType === "ordinal" + ? t(featureAttributeAllowedValuesFieldOrdinalLabel) + : t(featureAttributeAllowedValuesFieldNominalLabel); + const helperText = + featureType === "ordinal" + ? t("FeatureAttributes.FeatureAttributeAllowedValuesField.help.ordinal") + : t("FeatureAttributes.FeatureAttributeAllowedValuesField.help.nominal"); + const placeholder = getPlaceholder({ featureType, dataType, dateTimeFormat }); + + return ( + <> + + {/* + + {label} + + ( + + )} + /> + + {hasError ? translatedErrorMessage || : helperText} + + */} + + ); +}; + +const getPlaceholder = ({ + featureType, + dataType, + dateTimeFormat, +}: Pick< + FeatureAttributeAllowedValuesFieldProps, + "featureType" | "dataType" | "dateTimeFormat" +>): string => { + switch (true) { + case dataType === "formatted_date_time": + return `${dateTimeFormat} +${dateTimeFormat} +${dateTimeFormat} +${dateTimeFormat}`; + case featureType === "nominal" && dataType === "string": + return `Fibonacci +Pythagoras +Euclid +Albert Einstein +`; + case featureType === "nominal" && dataType === "number": + return `400 +200 +350 +100 +`; + case featureType === "ordinal" && dataType === "string": + return `xs +sm +md +lg +xl +`; + case featureType === "ordinal" && dataType === "number": + return `2 +3 +5 +7 +11 +13 +`; + default: + return ""; + } +}; + +export interface TextareaListProps + extends Omit { + value?: string[]; + defaultValue?: string[]; + onChange?: (newValue: string[]) => void; +} + +export const TextareaList = forwardRef( + ({ onChange, value, defaultValue, ...props }, ref) => { + const handleChange = useCallback>( + (event) => { + if (onChange) { + const newValue = event.currentTarget.value; + const values = newValue ? newValue.split("\n") : []; + onChange(values); + } + }, + [onChange], + ); + return ( +