From 3587ca27558ea1c5bc424ee81b4e0b94ac7c5053 Mon Sep 17 00:00:00 2001 From: James Dunkerley Date: Wed, 22 Jan 2025 00:27:44 +0000 Subject: [PATCH 1/4] In-Memory Refactor Part 3 (#12096) - Single method to make `Column` objects. - Remove `Java_Storage` and `Java_Column` use from everywhere which isn't `Column`. - Reviewed and cleaned up usage of Storage by enso code. --- .../src/Internal/SQLServer_Dialect.enso | 1 - .../src/Internal/Snowflake_Dialect.enso | 1 - .../Standard/Table/0.0.0-dev/src/Column.enso | 81 +++++++------------ .../0.0.0-dev/src/Internal/Column_Format.enso | 2 - .../src/Internal/Display_Helpers.enso | 26 +++++- .../0.0.0-dev/src/Internal/Java_Exports.enso | 1 - .../Standard/Table/0.0.0-dev/src/Table.enso | 10 +-- .../Tableau/0.0.0-dev/src/Hyper_Table.enso | 2 +- .../src/Table/Enso_Callback.enso | 2 +- .../src/Table/Helpers.enso | 2 +- 10 files changed, 59 insertions(+), 69 deletions(-) diff --git a/distribution/lib/Standard/Microsoft/0.0.0-dev/src/Internal/SQLServer_Dialect.enso b/distribution/lib/Standard/Microsoft/0.0.0-dev/src/Internal/SQLServer_Dialect.enso index 59585a7649b5..fff4c8963f7b 100644 --- a/distribution/lib/Standard/Microsoft/0.0.0-dev/src/Internal/SQLServer_Dialect.enso +++ b/distribution/lib/Standard/Microsoft/0.0.0-dev/src/Internal/SQLServer_Dialect.enso @@ -10,7 +10,6 @@ import Standard.Table.Internal.Vector_Builder.Vector_Builder from Standard.Table import Aggregate_Column, Column, Value_Type from Standard.Table.Aggregate_Column.Aggregate_Column import all from Standard.Table.Errors import Inexact_Type_Coercion -from Standard.Table.Internal.Storage import get_storage_for_column import Standard.Database.Connection.Connection.Connection import Standard.Database.DB_Column.DB_Column diff --git a/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Internal/Snowflake_Dialect.enso b/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Internal/Snowflake_Dialect.enso index c495c330b2d5..61aec4b4257b 100644 --- a/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Internal/Snowflake_Dialect.enso +++ b/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Internal/Snowflake_Dialect.enso @@ -11,7 +11,6 @@ import Standard.Table.Internal.Vector_Builder.Vector_Builder from Standard.Table import Aggregate_Column, Column, Value_Type from Standard.Table.Aggregate_Column.Aggregate_Column import all from Standard.Table.Errors import Inexact_Type_Coercion -from Standard.Table.Internal.Storage import get_storage_for_column import Standard.Database.Connection.Connection.Connection import Standard.Database.DB_Column.DB_Column diff --git a/distribution/lib/Standard/Table/0.0.0-dev/src/Column.enso b/distribution/lib/Standard/Table/0.0.0-dev/src/Column.enso index 6cf1bf731e1d..b8c696791c7e 100644 --- a/distribution/lib/Standard/Table/0.0.0-dev/src/Column.enso +++ b/distribution/lib/Standard/Table/0.0.0-dev/src/Column.enso @@ -118,7 +118,7 @@ type Column case needs_polyglot_conversion of True -> Java_Column.fromItems name (enso_to_java_maybe items) expected_storage_type java_problem_aggregator False -> Java_Column.fromItemsNoDateConversion name items expected_storage_type java_problem_aggregator - result = Column.Value java_column . throw_on_warning Conversion_Failure + result = Column.from_java_column java_column . throw_on_warning Conversion_Failure result.catch Conversion_Failure error-> if error.example_values.is_empty then result else raise_invalid_value_type_error error.example_values.first @@ -128,7 +128,14 @@ type Column from_storage : Text -> Java_Storage -> Column from_storage name storage = Invalid_Column_Names.handle_java_exception <| - Column.Value (Java_Column.new name storage) + java_column = Java_Column.new name storage + Column.from_java_column java_column + + ## PRIVATE + Creates a new column given a Java Column object. + from_java_column : Java_Column -> Column + from_java_column java_column = + Column.Value java_column ## PRIVATE ADVANCED @@ -143,7 +150,7 @@ type Column Invalid_Column_Names.handle_java_exception <| Illegal_Argument.handle_java_exception <| java_column = Java_Problems.with_problem_aggregator Problem_Behavior.Report_Warning java_problem_aggregator-> Java_Column.fromRepeatedItem name item repeats java_problem_aggregator - Column.Value java_column + Column.from_java_column java_column ## PRIVATE @@ -828,7 +835,7 @@ type Column rs = Panic.catch No_Such_Method handler=handle_no_iif <| Java_Problems.with_problem_aggregator Problem_Behavior.Report_Warning java_problem_aggregator-> s.iif true_val false_val storage_type java_problem_aggregator - Column.Value (Java_Column.new new_name rs) + Column.from_storage new_name rs ## PRIVATE @@ -1197,7 +1204,7 @@ type Column storage.fillMissingFrom other_storage storage_type java_problem_aggregator _ -> storage.fillMissing default storage_type java_problem_aggregator - Column.Value (Java_Column.new self.name new_storage) + Column.from_storage self.name new_storage ## ALIAS fill empty, if_empty GROUP Standard.Base.Values @@ -1846,7 +1853,7 @@ type Column new_storage = Java_Problems.with_problem_aggregator on_problems java_problem_aggregator-> parse_problem_aggregator = ParseProblemAggregator.make java_problem_aggregator self.name type parser.parseColumn storage parse_problem_aggregator - Column.Value (Java_Column.new self.name new_storage) + Column.from_storage self.name new_storage ## GROUP Standard.Base.Conversions ICON convert @@ -2175,7 +2182,7 @@ type Column rename : Text -> Column ! Illegal_Argument rename self name = naming_helper.ensure_name_is_valid name <| Illegal_Argument.handle_java_exception <| - Column.Value (self.java_column.rename name) + Column.from_java_column (self.java_column.rename name) ## GROUP Standard.Base.Metadata ICON metadata @@ -2284,8 +2291,7 @@ type Column valid_index = (index >= 0) && (index < self.length) if valid_index.not then default else storage = self.java_column.getStorage - if storage.isNothing index then Nothing else - java_to_enso <| storage.getItemBoxed index + java_to_enso <| storage.getItemBoxed index ## PRIVATE ICON data_input @@ -2428,7 +2434,7 @@ type Column rule = OrderBuilder.OrderRule.new self.java_column order_bool missing_last mask = OrderBuilder.buildOrderMask [rule] new_col = self.java_column.applyMask mask - Column.Value new_col + Column.from_java_column new_col _ -> wrapped a b = case a of Nothing -> if b.is_nothing then Ordering.Equal else if missing_last then Ordering.Greater else Ordering.Less @@ -2493,7 +2499,7 @@ type Column length = self.length offset = (start.min length).max 0 limit = ((end - offset).min (length - offset)).max 0 - Column.Value (self.java_column.slice offset limit) + Column.from_java_column (self.java_column.slice offset limit) ## GROUP Standard.Base.Selections ICON parse3 @@ -2541,7 +2547,7 @@ type Column reverse : Column reverse self = mask = OrderMask.reverse self.length - Column.Value (self.java_column.applyMask mask) + Column.from_java_column (self.java_column.applyMask mask) ## GROUP Standard.Base.Metadata ICON metadata @@ -2555,7 +2561,8 @@ type Column example_duplicate_count = Examples.integer_column.duplicate_count duplicate_count : Column - duplicate_count self = Column.Value self.java_column.duplicateCount + duplicate_count self = + Column.from_java_column self.java_column.duplicateCount ## PRIVATE Provides a simplified text representation for display in the REPL and errors. @@ -2647,7 +2654,7 @@ run_vectorized_many_op column name fallback_fn operands new_name=Nothing skip_nu current.vectorizedOrFallbackZip name problem_builder fallback_fn operand.java_column.getStorage skip_nulls storage_type _ -> Polyglot_Helpers.handle_polyglot_dataflow_errors <| current.vectorizedOrFallbackBinaryMap name problem_builder fallback_fn operand skip_nulls storage_type - Column.Value (Java_Column.new effective_new_name folded) + Column.from_storage effective_new_name folded ## PRIVATE @@ -2677,12 +2684,12 @@ run_vectorized_binary_op column name operand new_name=Nothing fallback_fn=Nothin s2 = col2.getStorage rs = Polyglot_Helpers.handle_polyglot_dataflow_errors <| s1.vectorizedOrFallbackZip name problem_builder fallback_fn s2 skip_nulls storage_type - Column.Value (Java_Column.new effective_new_name rs) + Column.from_storage effective_new_name rs _ -> s1 = column.java_column.getStorage rs = Polyglot_Helpers.handle_polyglot_dataflow_errors <| s1.vectorizedOrFallbackBinaryMap name problem_builder fallback_fn (enso_to_java operand) skip_nulls storage_type - Column.Value (Java_Column.new effective_new_name rs) + Column.from_storage effective_new_name rs ## PRIVATE @@ -2708,7 +2715,7 @@ run_vectorized_ternary_op column name operand0 operand1 new_name=Nothing expecte s1 = column.java_column.getStorage rs = Polyglot_Helpers.handle_polyglot_dataflow_errors <| s1.vectorizedTernaryMap name problem_builder operand0 operand1 skip_nulls storage_type - Column.Value (Java_Column.new effective_new_name rs) + Column.from_storage effective_new_name rs ## PRIVATE Runs a binary operation over the provided column and operand which may be @@ -2736,7 +2743,7 @@ run_binary_op column function operand new_name skip_nulls=True expected_result_t _ -> Polyglot_Helpers.handle_polyglot_dataflow_errors <| s.binaryMap function operand skip_nulls storage_type problem_builder - Column.Value (Java_Column.new new_name new_storage) + Column.from_storage new_name new_storage ## PRIVATE @@ -2770,46 +2777,18 @@ run_vectorized_binary_op_with_fallback_problem_handling column name operand fall s2 = col2.getStorage rs = Polyglot_Helpers.handle_polyglot_dataflow_errors <| s1.vectorizedOrFallbackZip name problem_builder applied_fn s2 skip_nulls storage_type - Column.Value (Java_Column.new new_name rs) + Column.from_storage new_name rs _ -> s1 = column.java_column.getStorage rs = Polyglot_Helpers.handle_polyglot_dataflow_errors <| s1.vectorizedOrFallbackBinaryMap name problem_builder applied_fn (enso_to_java operand) skip_nulls storage_type - Column.Value (Java_Column.new new_name rs) - -## PRIVATE - - Gets a textual representation of the item at position `ix` in `column`. - - Arguments: - - column: The column to get the item from. - - ix: The index in the column from which to get the item. -get_item_as_text : Column -> Integer -> Text -get_item_as_text column ix = - item = column.getItemBoxed ix - ## TODO This special handling of `Text` is because `"a".to_text` evaluates - to "'a'" and not just "a". The code can be simplified once the following - task is implemented: - https://www.pivotaltracker.com/story/show/181499256 - case item of - _ : Text -> normalize_text_for_display item - _ -> item.pretty - -## PRIVATE - Ensures that the text can be safely displayed in a terminal. - - If the string contains special characters, it will be wrapped in quotes and - the characters escaped. Otherwise, the string is returned as-is. -normalize_text_for_display text = - prettified = text.pretty - just_quoted = "'" + text + "'" - if prettified == just_quoted then text else prettified + Column.from_storage new_name rs ## PRIVATE A helper to create a new table consisting of slices of the original table. slice_ranges column ranges = normalized = normalize_ranges ranges - Column.Value (column.java_column.slice normalized) + Column.from_java_column (column.java_column.slice normalized) ## PRIVATE Creates a storage builder suitable for building a column for the provided @@ -2928,7 +2907,7 @@ apply_unary_operation column:Column operation:UnaryOperation new_name:Text|Nothi used_name = new_name.if_nothing (naming_helper.function_name operation.getName [column]) Java_Problems.with_map_operation_problem_aggregator column.name Problem_Behavior.Report_Warning java_problem_aggregator-> java_column = UnaryOperation.apply column.java_column operation used_name java_problem_aggregator - if java_column.is_nothing then if_unsupported else Column.Value java_column + if java_column.is_nothing then if_unsupported else Column.from_java_column java_column ## PRIVATE Applies a function to every row in the column. @@ -2945,7 +2924,7 @@ apply_unary_map column:Column new_name:Text function expected_result_type:Value_ storage_type = resolve_storage_type expected_result_type Java_Problems.with_map_operation_problem_aggregator column.name Problem_Behavior.Report_Warning java_problem_aggregator-> map_column = UnaryOperation.mapFunction column.java_column function nothing_unchanged storage_type new_name java_problem_aggregator - Column.Value map_column + Column.from_java_column map_column ## PRIVATE Many_Files_List.from (that : Column) = diff --git a/distribution/lib/Standard/Table/0.0.0-dev/src/Internal/Column_Format.enso b/distribution/lib/Standard/Table/0.0.0-dev/src/Internal/Column_Format.enso index 26cbaaae2eea..a9299f15fd97 100644 --- a/distribution/lib/Standard/Table/0.0.0-dev/src/Internal/Column_Format.enso +++ b/distribution/lib/Standard/Table/0.0.0-dev/src/Internal/Column_Format.enso @@ -9,8 +9,6 @@ import project.Value_Type.Value_Type polyglot java import java.lang.IllegalArgumentException polyglot java import java.time.temporal.UnsupportedTemporalTypeException -polyglot java import org.enso.table.data.column.storage.Storage as Java_Storage -polyglot java import org.enso.table.data.table.Column as Java_Column ## PRIVATE Create a formatter for the specified `Value_Type`. diff --git a/distribution/lib/Standard/Table/0.0.0-dev/src/Internal/Display_Helpers.enso b/distribution/lib/Standard/Table/0.0.0-dev/src/Internal/Display_Helpers.enso index 4a3bd2a99b17..04c753efc74e 100644 --- a/distribution/lib/Standard/Table/0.0.0-dev/src/Internal/Display_Helpers.enso +++ b/distribution/lib/Standard/Table/0.0.0-dev/src/Internal/Display_Helpers.enso @@ -1,7 +1,6 @@ from Standard.Base import all import project.Table.Table -from project.Column import get_item_as_text, normalize_text_for_display polyglot java import java.lang.System as Java_System @@ -22,12 +21,11 @@ polyglot java import java.lang.System as Java_System codes for rich formatting in the terminal. display_table (table : Table) (add_row_index : Boolean) (max_rows_to_show : Integer) (all_rows_count : Integer) (format_terminal : Boolean) -> Text = cols = Vector.from_polyglot_array table.java_table.getColumns - col_names = cols.map .getName . map normalize_text_for_display + col_names = cols.map .getName . map _normalize_text_for_display col_vals = cols.map .getStorage display_rows = table.row_count.min max_rows_to_show rows = Vector.new display_rows row_num-> - cols = col_vals.map col-> - if col.isNothing row_num then "Nothing" else get_item_as_text col row_num + cols = col_vals.map col-> _get_item_as_text col row_num if add_row_index then [row_num.to_text] + cols else cols table_text = case add_row_index of True -> print_table [""]+col_names rows 1 format_terminal @@ -87,3 +85,23 @@ pad txt len = ansi_bold : Boolean -> Text -> Text ansi_bold enabled txt = if enabled && (Java_System.console != Nothing) then '\e[1m' + txt + '\e[m' else txt + +## PRIVATE + Gets a textual representation of the item at position `ix` in `storage`. +private _get_item_as_text storage ix = + item = storage.getItemBoxed ix + ## Special handling for display of Text to avoid quotes when not necessary. + case item of + _ : Text -> _normalize_text_for_display item + _ -> item.pretty + + +## PRIVATE + Ensures that the text can be safely displayed in a terminal. + + If the string contains special characters, it will be wrapped in quotes and + the characters escaped. Otherwise, the string is returned as-is. +private _normalize_text_for_display text = + prettified = text.pretty + just_quoted = "'" + text + "'" + if prettified == just_quoted then text else prettified diff --git a/distribution/lib/Standard/Table/0.0.0-dev/src/Internal/Java_Exports.enso b/distribution/lib/Standard/Table/0.0.0-dev/src/Internal/Java_Exports.enso index b3980b1b7347..1efa888f7021 100644 --- a/distribution/lib/Standard/Table/0.0.0-dev/src/Internal/Java_Exports.enso +++ b/distribution/lib/Standard/Table/0.0.0-dev/src/Internal/Java_Exports.enso @@ -9,7 +9,6 @@ polyglot java import org.enso.table.data.column.builder.Builder polyglot java import org.enso.table.data.column.builder.BuilderForBoolean polyglot java import org.enso.table.data.column.builder.BuilderForDouble polyglot java import org.enso.table.data.column.builder.BuilderForLong -polyglot java import org.enso.table.data.column.storage.Storage as Java_Storage polyglot java import org.enso.table.problems.ProblemAggregator ## PRIVATE diff --git a/distribution/lib/Standard/Table/0.0.0-dev/src/Table.enso b/distribution/lib/Standard/Table/0.0.0-dev/src/Table.enso index 23298ede0727..0797ccce24e5 100644 --- a/distribution/lib/Standard/Table/0.0.0-dev/src/Table.enso +++ b/distribution/lib/Standard/Table/0.0.0-dev/src/Table.enso @@ -85,7 +85,6 @@ polyglot java import java.util.UUID polyglot java import org.enso.base.ObjectComparator polyglot java import org.enso.table.data.index.MultiValueIndex polyglot java import org.enso.table.data.mask.OrderMask -polyglot java import org.enso.table.data.table.Column as Java_Column polyglot java import org.enso.table.data.table.join.conditions.Between as Java_Join_Between polyglot java import org.enso.table.data.table.join.conditions.Equals as Java_Join_Equals polyglot java import org.enso.table.data.table.join.conditions.EqualsIgnoreCase as Java_Join_Equals_Ignore_Case @@ -342,8 +341,7 @@ type Table at : Integer | Text -> Column ! No_Such_Column | Index_Out_Of_Bounds at self (selector:(Integer | Text)=0) = case selector of _ : Integer -> - java_columns = Vector.from_polyglot_array self.java_table.getColumns - Column.Value (java_columns.at selector) + Column.from_java_column (self.java_table.getColumns.at selector) _ -> self.get selector (Error.throw (No_Such_Column.Error selector)) ## ICON select_column @@ -386,7 +384,7 @@ type Table java_column = case selector of _ : Integer -> Vector.from_polyglot_array self.java_table.getColumns . get selector _ : Text -> self.java_table.getColumnByName selector - if java_column.is_nothing then if_missing else Column.Value java_column + if java_column.is_nothing then if_missing else Column.from_java_column java_column ## ALIAS cell value, get cell GROUP Standard.Base.Selections @@ -1569,7 +1567,7 @@ type Table new_storage = Java_Problems.with_problem_aggregator on_problems java_problem_aggregator-> parse_problem_aggregator = ParseProblemAggregator.make java_problem_aggregator column.name type parser.parseColumn storage parse_problem_aggregator - Column.Value (Java_Column.new column.name new_storage) + Column.from_storage column.name new_storage Table.new new_columns ## GROUP Standard.Base.Conversions @@ -2599,7 +2597,7 @@ type Table columns : Vector columns self = Vector.from_polyglot_array <| Array_Proxy.new self.java_table.getColumns.length i-> - Column.Value (self.java_table.getColumns.at i) + Column.from_java_column (self.java_table.getColumns.at i) ## GROUP Standard.Base.Metadata ICON metadata diff --git a/distribution/lib/Standard/Tableau/0.0.0-dev/src/Hyper_Table.enso b/distribution/lib/Standard/Tableau/0.0.0-dev/src/Hyper_Table.enso index 6b500f212fcc..240f8d9d1cd1 100644 --- a/distribution/lib/Standard/Tableau/0.0.0-dev/src/Hyper_Table.enso +++ b/distribution/lib/Standard/Tableau/0.0.0-dev/src/Hyper_Table.enso @@ -95,5 +95,5 @@ type Hyper_Table Java_Problems.with_problem_aggregator Problem_Behavior.Report_Warning java_problem_aggregator-> row_count = if max_rows == Rows_To_Read.All_Rows then Nothing else max_rows.rows java_columns = HyperReader.readTable self.file.file.path self.schema self.table row_count java_problem_aggregator - enso_columns = java_columns.map c-> Column.from_storage c.getName c.getStorage + enso_columns = java_columns.map Column.from_java_column Table.new enso_columns diff --git a/test/Exploratory_Benchmarks/src/Table/Enso_Callback.enso b/test/Exploratory_Benchmarks/src/Table/Enso_Callback.enso index b8f194853df9..5df09fd890b5 100644 --- a/test/Exploratory_Benchmarks/src/Table/Enso_Callback.enso +++ b/test/Exploratory_Benchmarks/src/Table/Enso_Callback.enso @@ -83,7 +83,7 @@ type Primitive_Enso_Callback_Test expected_storage_type = Storage.from_value_type_strict Value_Type.Integer java_column = Java_Problems.with_problem_aggregator ..Report_Error java_problem_aggregator-> Java_Column.fromItemsNoDateConversion "result" mapped expected_storage_type java_problem_aggregator - Column.from_storage java_column.getName java_column.getStorage + Column.from_java_column java_column enso_map_with_builder_2_calls_unboxed self = n = self.int_column.length diff --git a/test/Exploratory_Benchmarks/src/Table/Helpers.enso b/test/Exploratory_Benchmarks/src/Table/Helpers.enso index a5f5fe8980a6..df9a656c4ba5 100644 --- a/test/Exploratory_Benchmarks/src/Table/Helpers.enso +++ b/test/Exploratory_Benchmarks/src/Table/Helpers.enso @@ -21,7 +21,7 @@ column_from_vector name items convert_polyglot_dates = Java_Column.fromItems name items expected_storage_type java_problem_aggregator False -> Java_Column.fromItemsNoDateConversion name items expected_storage_type java_problem_aggregator - Column.from_storage java_column.getName java_column.getStorage + Column.from_java_column java_column check_results results = mapped = results.map x-> case x of From cb7601e3c9755189ecccfd8bb8a6034687eda9bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Wa=C5=9Bko?= Date: Wed, 22 Jan 2025 11:00:23 +0100 Subject: [PATCH 2/4] Fix DB maintenance actions broken by #7117 (#12105) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running some tests I noticed logs like ``` sty 21, 2025 10:12:30 PM org.enso.database.dryrun.OperationSynchronizer runMaintenanceActionIfPossible SEVERE: A maintenance action failed with exception: As writing is disabled, cannot execute an update query. Press the Write button ▶ to perform the operation. ``` They appear rather randomly. This PR should provide a workaround until #7117 is fixed. --- .../Database/0.0.0-dev/src/Internal/JDBC_Connection.enso | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/distribution/lib/Standard/Database/0.0.0-dev/src/Internal/JDBC_Connection.enso b/distribution/lib/Standard/Database/0.0.0-dev/src/Internal/JDBC_Connection.enso index f97548ba4527..eeebb90e45aa 100644 --- a/distribution/lib/Standard/Database/0.0.0-dev/src/Internal/JDBC_Connection.enso +++ b/distribution/lib/Standard/Database/0.0.0-dev/src/Internal/JDBC_Connection.enso @@ -65,7 +65,11 @@ type JDBC_Connection `synchronized` critical section (including the current thread). run_maintenance_action_if_possible : (Nothing -> Any) -> Nothing run_maintenance_action_if_possible self callback = - self.operation_synchronizer.runMaintenanceActionIfPossible callback + # TODO The wrapping is a workaround for https://github.com/enso-org/enso/issues/7117 + wrapped_callback x = + Context.Output.with_enabled <| + callback x + self.operation_synchronizer.runMaintenanceActionIfPossible wrapped_callback ## PRIVATE From 58e1b3d4d52bffb8ad3183193663c82e1b57ab70 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Wed, 22 Jan 2025 22:01:09 +1000 Subject: [PATCH 3/4] Async Execution (project scheduling) (#10827) :warning: This is currently blocked waiting for the corresponding backend functionality. - Frontend part of https://github.com/enso-org/cloud-v2/issues/1392 - New modal to schedule project executions - New tab in asset right menu that lists project executions - Choose repeat interval (hourly, daily, weekly, monthly) - Choose parallel mode to control behavior when multiple executions are scheduled at the same time (ignore new execution if old one is not finished, cancel old execution, run both executions at the same time) - Select and display maximum execution time - "Use Current Version" button - Paused ("disabled") schedules # Important Notes None --- app/common/src/services/Backend.ts | 170 ++++++- .../Backend/__test__/projectExecution.test.ts | 100 ++++ .../src/services/Backend/projectExecution.ts | 184 ++++++++ app/common/src/text.ts | 100 ++-- app/common/src/text/english.json | 109 ++++- app/common/src/utilities/data/array.ts | 4 +- app/common/src/utilities/data/dateTime.ts | 66 ++- .../integration-test/dashboard/actions/api.ts | 2 +- .../integration-test/dashboard/sort.spec.ts | 2 +- app/gui/src/dashboard/App.tsx | 8 +- app/gui/src/dashboard/assets/arrow_left.svg | 2 +- app/gui/src/dashboard/assets/arrow_right.svg | 2 +- .../src/dashboard/assets/arrows_repeat.svg | 3 + app/gui/src/dashboard/assets/calendar.svg | 17 + .../assets/calendar_repeat_outline.svg | 5 + app/gui/src/dashboard/assets/parallel.svg | 6 + app/gui/src/dashboard/assets/repeat.svg | 6 + app/gui/src/dashboard/assets/stop.svg | 2 +- app/gui/src/dashboard/assets/stop2.svg | 3 + app/gui/src/dashboard/assets/upgrade.svg | 7 + .../src/dashboard/authentication/cognito.ts | 2 +- .../components/AnimatedBackground.tsx | 23 +- .../AriaComponents/Checkbox/Checkbox.tsx | 30 +- .../AriaComponents/Checkbox/CheckboxGroup.tsx | 18 +- .../AriaComponents/Dialog/Dialog.tsx | 6 +- .../components/AriaComponents/Form/Form.tsx | 2 + .../AriaComponents/Form/components/Field.tsx | 9 +- .../Form/components/FieldValue.tsx | 14 +- .../AriaComponents/Form/components/types.ts | 36 +- .../Form/components/useField.ts | 39 +- .../Form/components/useFieldRegister.ts | 23 +- .../Form/components/useFieldState.ts | 18 +- .../AriaComponents/Form/components/useForm.ts | 4 +- .../components/AriaComponents/Form/types.ts | 4 +- .../Inputs/ComboBox/ComboBox.tsx | 15 +- .../Inputs/DatePicker/DatePicker.tsx | 33 +- .../Inputs/Dropdown/Dropdown.tsx | 84 +++- .../Inputs/HiddenFile/HiddenFile.tsx | 7 +- .../AriaComponents/Inputs/Input/Input.tsx | 28 +- .../MultiSelector/MultiSelector.stories.tsx | 2 +- .../Inputs/MultiSelector/MultiSelector.tsx | 35 +- .../Inputs/OTPInput/OTPInput.tsx | 6 +- .../Inputs/Password/Password.tsx | 14 +- .../ResizableContentEditableInput.tsx | 17 +- .../Inputs/Selector/Selector.stories.tsx | 2 +- .../Inputs/Selector/Selector.tsx | 24 +- .../AriaComponents/Radio/RadioGroup.tsx | 41 +- .../AriaComponents/Switch/Switch.tsx | 37 +- .../components/AriaComponents/Text/Text.tsx | 1 + .../components/Devtools/EnsoDevtools.tsx | 342 +------------- .../components/Devtools/EnsoDevtoolsImpl.tsx | 364 +++++++++++++++ .../Devtools/EnsoDevtoolsProvider.tsx | 55 ++- .../Devtools/ReactQueryDevtools.tsx | 2 +- .../src/dashboard/components/MenuEntry.tsx | 1 + app/gui/src/dashboard/components/aria.tsx | 4 +- .../components/dashboard/AssetSummary.tsx | 2 +- .../components/dashboard/ProjectIcon.tsx | 1 + .../dashboard/column/ModifiedColumn.tsx | 2 +- .../dashboard/column/PathColumn.tsx | 8 +- .../dashboard/configurations/inputBindings.ts | 6 + app/gui/src/dashboard/hooks/backendHooks.ts | 8 + app/gui/src/dashboard/hooks/ordinalHooks.ts | 24 + app/gui/src/dashboard/hooks/projectHooks.ts | 6 +- .../layouts/AssetPanel/AssetPanel.tsx | 85 ++-- .../layouts/AssetPanel/AssetPanelState.ts | 8 +- .../components/AssetPanelPlaceholder.tsx | 14 + .../AssetPanel/components/AssetPanelTabs.tsx | 14 +- .../components}/AssetProperties.tsx | 16 +- .../components}/AssetVersion.tsx | 6 +- .../components}/AssetVersions.tsx | 70 ++- .../components/ProjectExecution.tsx | 215 +++++++++ .../components/ProjectExecutions.tsx | 80 ++++ .../components/ProjectExecutionsCalendar.tsx | 232 ++++++++++ .../components/ProjectSession.tsx} | 13 +- .../components/ProjectSessions.tsx} | 35 +- .../components}/useAssetVersions.ts | 24 +- .../dashboard/layouts/CategorySwitcher.tsx | 3 +- app/gui/src/dashboard/layouts/Chat.tsx | 2 +- .../layouts/NewProjectExecutionModal.tsx | 428 ++++++++++++++++++ .../Settings/ActivityLogSettingsSection.tsx | 2 +- .../dashboard/layouts/Settings/AriaInput.tsx | 33 +- .../src/dashboard/layouts/Settings/data.tsx | 58 +-- .../src/dashboard/layouts/Settings/index.tsx | 5 + app/gui/src/dashboard/layouts/UserMenu.tsx | 12 + .../dashboard/pages/dashboard/Dashboard.tsx | 10 +- .../pages/dashboard/DashboardTabBar.tsx | 98 ++-- .../pages/dashboard/DashboardTabPanels.tsx | 11 +- .../src/dashboard/providers/AuthProvider.tsx | 19 + .../providers/FeatureFlagsProvider.tsx | 41 +- .../dashboard/providers/ProjectsProvider.tsx | 16 +- .../__test__/SessionProvider.test.tsx | 2 +- .../src/dashboard/services/LocalBackend.ts | 25 + .../src/dashboard/services/ProjectManager.ts | 2 +- .../src/dashboard/services/RemoteBackend.ts | 98 +++- .../services/__test__/Backend.test.ts | 2 +- .../dashboard/services/remoteBackendPaths.ts | 22 +- .../utilities/__tests__/dateTime.test.ts | 20 +- app/gui/src/dashboard/utilities/dateTime.ts | 3 - app/gui/src/dashboard/utilities/objectPath.ts | 66 +++ eslint.config.mjs | 4 +- 100 files changed, 3074 insertions(+), 917 deletions(-) create mode 100644 app/common/src/services/Backend/__test__/projectExecution.test.ts create mode 100644 app/common/src/services/Backend/projectExecution.ts create mode 100644 app/gui/src/dashboard/assets/arrows_repeat.svg create mode 100644 app/gui/src/dashboard/assets/calendar.svg create mode 100644 app/gui/src/dashboard/assets/calendar_repeat_outline.svg create mode 100644 app/gui/src/dashboard/assets/parallel.svg create mode 100644 app/gui/src/dashboard/assets/repeat.svg create mode 100644 app/gui/src/dashboard/assets/stop2.svg create mode 100644 app/gui/src/dashboard/assets/upgrade.svg create mode 100644 app/gui/src/dashboard/components/Devtools/EnsoDevtoolsImpl.tsx create mode 100644 app/gui/src/dashboard/hooks/ordinalHooks.ts create mode 100644 app/gui/src/dashboard/layouts/AssetPanel/components/AssetPanelPlaceholder.tsx rename app/gui/src/dashboard/layouts/{ => AssetPanel/components}/AssetProperties.tsx (97%) rename app/gui/src/dashboard/layouts/{AssetVersions => AssetPanel/components}/AssetVersion.tsx (96%) rename app/gui/src/dashboard/layouts/{AssetVersions => AssetPanel/components}/AssetVersions.tsx (69%) create mode 100644 app/gui/src/dashboard/layouts/AssetPanel/components/ProjectExecution.tsx create mode 100644 app/gui/src/dashboard/layouts/AssetPanel/components/ProjectExecutions.tsx create mode 100644 app/gui/src/dashboard/layouts/AssetPanel/components/ProjectExecutionsCalendar.tsx rename app/gui/src/dashboard/layouts/{AssetProjectSession.tsx => AssetPanel/components/ProjectSession.tsx} (78%) rename app/gui/src/dashboard/layouts/{AssetProjectSessions.tsx => AssetPanel/components/ProjectSessions.tsx} (68%) rename app/gui/src/dashboard/layouts/{AssetVersions => AssetPanel/components}/useAssetVersions.ts (65%) create mode 100644 app/gui/src/dashboard/layouts/NewProjectExecutionModal.tsx delete mode 100644 app/gui/src/dashboard/utilities/dateTime.ts create mode 100644 app/gui/src/dashboard/utilities/objectPath.ts diff --git a/app/common/src/services/Backend.ts b/app/common/src/services/Backend.ts index aa7c6693a9c1..b9d09c2f0cdb 100644 --- a/app/common/src/services/Backend.ts +++ b/app/common/src/services/Backend.ts @@ -1,5 +1,6 @@ /** @file Type definitions common between all backends. */ +import type { TextId } from '../text' import * as array from '../utilities/data/array' import * as dateTime from '../utilities/data/dateTime' import * as newtype from '../utilities/data/newtype' @@ -79,6 +80,10 @@ export const SecretId = newtype.newtypeConstructor() export type ProjectSessionId = newtype.Newtype export const ProjectSessionId = newtype.newtypeConstructor() +/** Unique identifier for a project execution. */ +export type ProjectExecutionId = newtype.Newtype +export const ProjectExecutionId = newtype.newtypeConstructor() + /** Unique identifier for a Datalink. */ export type DatalinkId = newtype.Newtype export const DatalinkId = newtype.newtypeConstructor() @@ -208,6 +213,8 @@ export interface User extends UserInfo { * Has enriched metadata, like the name of the group and the home directory ID. */ readonly groups?: readonly UserGroup[] + /** Whether the user is a member of the Enso team. */ + readonly isEnsoTeamMember: boolean } /** A user related to the current user. */ @@ -345,6 +352,127 @@ export interface ProjectSession { readonly userEmail: EmailAddress } +export const PROJECT_PARALLEL_MODES = ['ignore', 'restart', 'parallel'] as const + +export const PARALLEL_MODE_TO_TEXT_ID = { + ignore: 'ignoreParallelMode', + restart: 'restartParallelMode', + parallel: 'parallelParallelMode', +} satisfies { + [K in ProjectParallelMode]: TextId & `${K}ParallelMode` +} + +export const PARALLEL_MODE_TO_DESCRIPTION_ID = { + ignore: 'ignoreParallelModeDescription', + restart: 'restartParallelModeDescription', + parallel: 'parallelParallelModeDescription', +} satisfies { + [K in ProjectParallelMode]: TextId & `${K}ParallelModeDescription` +} + +/** + * The behavior when manually starting a new execution when the previous one is not yet complete. + * One of the following: + * - `ignore` - do not start the new execution. + * - `restart` - stop the old execution and start the new execution. + * - `parallel` - keep the old execution running but also run the new execution. + */ +export type ProjectParallelMode = (typeof PROJECT_PARALLEL_MODES)[number] + +export const PROJECT_EXECUTION_REPEAT_TYPES = [ + 'none', + 'hourly', + 'daily', + 'monthly-date', + 'monthly-weekday', + 'monthly-last-weekday', +] as const + +export const PROJECT_EXECUTION_REPEAT_TYPE_TO_TEXT_ID = { + none: 'noneProjectExecutionRepeatType', + hourly: 'hourlyProjectExecutionRepeatType', + daily: 'dailyProjectExecutionRepeatType', + 'monthly-date': 'monthlyProjectExecutionRepeatType', + 'monthly-weekday': 'monthlyProjectExecutionRepeatType', + 'monthly-last-weekday': 'monthlyProjectExecutionRepeatType', +} satisfies { + readonly [K in ProjectExecutionRepeatType]: TextId & `${string}ProjectExecutionRepeatType` +} + +/** The interval at which a project schedule repeats. */ +export type ProjectExecutionRepeatType = ProjectExecutionRepeatInfo['type'] + +/** Details for a project execution that repeats hourly. */ +export interface ProjectExecutionNoneRepeatInfo { + readonly type: 'none' +} + +/** Details for a project execution that repeats hourly. */ +export interface ProjectExecutionHourlyRepeatInfo { + readonly type: 'hourly' + readonly startHour: number + readonly endHour: number +} + +/** Details for a project execution that repeats daily. */ +export interface ProjectExecutionDailyRepeatInfo { + readonly type: 'daily' + readonly daysOfWeek: readonly number[] +} + +/** Details for a project execution that repeats monthly on a specific date. */ +export interface ProjectExecutionMonthlyDateRepeatInfo { + readonly type: 'monthly-date' + readonly date: number + readonly months: readonly number[] +} + +/** + * Details for a project execution that repeats monthly on a specific weekday of a specific week + * of a specific month. + */ +export interface ProjectExecutionMonthlyWeekdayRepeatInfo { + readonly type: 'monthly-weekday' + readonly weekNumber: number + readonly dayOfWeek: number + readonly months: readonly number[] +} + +/** + * Details for a project execution that repeats monthly on a specific weekday of the last week + * of a specific month. + */ +export interface ProjectExecutionMonthlyLastWeekdayRepeatInfo { + readonly type: 'monthly-last-weekday' + readonly dayOfWeek: number + readonly months: readonly number[] +} + +export type ProjectExecutionRepeatInfo = + | ProjectExecutionHourlyRepeatInfo + | ProjectExecutionDailyRepeatInfo + | ProjectExecutionMonthlyDateRepeatInfo + | ProjectExecutionMonthlyWeekdayRepeatInfo + | ProjectExecutionMonthlyLastWeekdayRepeatInfo + | ProjectExecutionNoneRepeatInfo + +/** Metadata for a {@link ProjectExecution}. */ +export interface ProjectExecutionInfo { + readonly projectId: ProjectId + readonly timeZone: string + readonly repeat: ProjectExecutionRepeatInfo + readonly parallelMode: ProjectParallelMode + readonly maxDurationMinutes: number + readonly startDate: dateTime.Rfc3339DateTime +} + +/** A specific execution schedule of a project. */ +export interface ProjectExecution extends ProjectExecutionInfo { + readonly enabled: boolean + readonly executionId: ProjectExecutionId + readonly versionId: S3ObjectVersionId +} + /** Metadata describing the location of an uploaded file. */ export interface FileLocator { readonly fileId: FileId @@ -668,19 +796,19 @@ export const COLORS = [ { lightness: 50, chroma: 66, hue: 34 }, // Yellow { lightness: 50, chroma: 66, hue: 80 }, - // Turquoise + // Green { lightness: 50, chroma: 66, hue: 139 }, // Teal { lightness: 50, chroma: 66, hue: 172 }, // Blue { lightness: 50, chroma: 66, hue: 271 }, - // Lavender + // Purple { lightness: 50, chroma: 66, hue: 295 }, // Pink { lightness: 50, chroma: 66, hue: 332 }, - // Light blue + // Light blueish grey { lightness: 50, chroma: 22, hue: 252 }, - // Dark blue + // Dark blueish grey { lightness: 22, chroma: 13, hue: 252 }, ] as const satisfies LChColor[] @@ -1309,6 +1437,14 @@ export interface OpenProjectRequestBody { readonly parentId: DirectoryId } +/** HTTP request body for the "create project execution" endpoint. */ +export interface CreateProjectExecutionRequestBody extends ProjectExecutionInfo {} + +/** HTTP request body for the "update project execution" endpoint. */ +export interface UpdateProjectExecutionRequestBody { + readonly enabled?: boolean | undefined +} + /** HTTP request body for the "create secret" endpoint. */ export interface CreateSecretRequestBody { readonly name: string @@ -1684,11 +1820,35 @@ export default abstract class Backend { abstract createProject(body: CreateProjectRequestBody): Promise /** Close a project. */ abstract closeProject(projectId: ProjectId, title: string): Promise - /** Return a list of sessions for the current project. */ + /** Return a list of sessions for a project. */ abstract listProjectSessions( projectId: ProjectId, title: string, ): Promise + /** Create a project execution. */ + abstract createProjectExecution( + body: CreateProjectExecutionRequestBody, + title: string, + ): Promise + abstract updateProjectExecution( + executionId: ProjectExecutionId, + body: UpdateProjectExecutionRequestBody, + projectTitle: string, + ): Promise + /** Delete a project execution. */ + abstract deleteProjectExecution( + executionId: ProjectExecutionId, + projectTitle: string, + ): Promise + /** Return a list of executions for a project. */ + abstract listProjectExecutions( + projectId: ProjectId, + title: string, + ): Promise + abstract syncProjectExecution( + executionId: ProjectExecutionId, + projectTitle: string, + ): Promise /** Restore a project from a different version. */ abstract restoreProject( projectId: ProjectId, diff --git a/app/common/src/services/Backend/__test__/projectExecution.test.ts b/app/common/src/services/Backend/__test__/projectExecution.test.ts new file mode 100644 index 000000000000..19201359d015 --- /dev/null +++ b/app/common/src/services/Backend/__test__/projectExecution.test.ts @@ -0,0 +1,100 @@ +import * as v from 'vitest' +import { toRfc3339 } from '../../../utilities/data/dateTime' +import { ProjectExecutionInfo, ProjectId } from '../../Backend' +import { firstProjectExecutionOnOrAfter, nextProjectExecutionDate } from '../projectExecution' + +const HOURLY_EXECUTION_1: ProjectExecutionInfo = { + projectId: ProjectId('project-aaaaaaaa'), + repeat: { + type: 'hourly', + startHour: 7, + endHour: 15, + }, + startDate: toRfc3339(new Date(2020, 0, 1, 10, 59)), + timeZone: 'UTC', + maxDurationMinutes: 60, + parallelMode: 'ignore', +} + +const HOURLY_EXECUTION_2: ProjectExecutionInfo = { + projectId: ProjectId('project-aaaaaaaa'), + repeat: { + type: 'hourly', + startHour: 20, + endHour: 4, + }, + startDate: toRfc3339(new Date(2015, 2, 8, 22, 33)), + timeZone: 'UTC', + maxDurationMinutes: 60, + parallelMode: 'ignore', +} + +const DAILY_EXECUTION: ProjectExecutionInfo = { + projectId: ProjectId('project-aaaaaaaa'), + repeat: { + type: 'daily', + daysOfWeek: [0, 5], + }, + startDate: toRfc3339(new Date(2000, 0, 1, 7, 3)), + timeZone: 'UTC', + maxDurationMinutes: 60, + parallelMode: 'ignore', +} + +v.test.each([ + { + info: DAILY_EXECUTION, + current: new Date(2000, 5, 4, 7, 3), + next1: new Date(2000, 5, 9, 7, 3), + next2: new Date(2000, 5, 11, 7, 3), + next3: new Date(2000, 5, 16, 7, 3), + }, + { + info: HOURLY_EXECUTION_1, + current: new Date(2022, 10, 21, 14, 59), + next1: new Date(2022, 10, 21, 15, 59), + next2: new Date(2022, 10, 22, 7, 59), + next3: new Date(2022, 10, 22, 8, 59), + }, + { + info: HOURLY_EXECUTION_2, + current: new Date(2018, 4, 11, 3, 33), + next1: new Date(2018, 4, 11, 4, 33), + next2: new Date(2018, 4, 11, 20, 33), + next3: new Date(2018, 4, 11, 21, 33), + }, + { + info: HOURLY_EXECUTION_2, + current: new Date(2018, 4, 11, 23, 33), + next1: new Date(2018, 4, 12, 0, 33), + next2: new Date(2018, 4, 12, 1, 33), + next3: new Date(2018, 4, 12, 2, 33), + }, +] satisfies readonly { + info: ProjectExecutionInfo + current: Date + next1: Date + next2: Date + next3: Date +}[])( + 'Get next project execution date (current: $current)', + ({ info, current, next1, next2, next3 }) => { + v.expect(nextProjectExecutionDate(info, current)).toStrictEqual(next1) + v.expect(nextProjectExecutionDate(info, next1)).toStrictEqual(next2) + v.expect(nextProjectExecutionDate(info, next2)).toStrictEqual(next3) + }, +) + +v.test.each([ + { info: DAILY_EXECUTION, current: new Date(1999, 1, 1), next: new Date(2000, 0, 2, 7, 3) }, + { info: DAILY_EXECUTION, current: new Date(2000, 10, 16), next: new Date(2000, 10, 17, 7, 3) }, +] satisfies readonly { + info: ProjectExecutionInfo + current: Date + next: Date +}[])( + 'Get first project execution date on or after (current: $current)', + ({ info, current, next }) => { + v.expect(firstProjectExecutionOnOrAfter(info, current)).toStrictEqual(next) + }, +) diff --git a/app/common/src/services/Backend/projectExecution.ts b/app/common/src/services/Backend/projectExecution.ts new file mode 100644 index 000000000000..9c092571db20 --- /dev/null +++ b/app/common/src/services/Backend/projectExecution.ts @@ -0,0 +1,184 @@ +import { EMPTY_ARRAY } from '../../utilities/data/array' +import { ProjectExecutionInfo } from '../Backend' + +const DAYS_PER_WEEK = 7 +const MONTHS_PER_YEAR = 12 + +/** The first execution date of the given {@link ProjectExecution} on or after the given date. */ +export function firstProjectExecutionOnOrAfter( + projectExecution: ProjectExecutionInfo, + startDate: Date, +): Date { + // TODO: Account for timezone. + let nextDate = new Date(startDate) + const { repeat } = projectExecution + const executionStartDate = new Date(projectExecution.startDate) + if (nextDate < executionStartDate) { + nextDate = new Date(executionStartDate) + } + nextDate.setMinutes(executionStartDate.getMinutes()) + if (repeat.type !== 'hourly') { + nextDate.setHours(executionStartDate.getHours()) + } + switch (repeat.type) { + case 'hourly': { + while (nextDate < startDate) { + nextDate.setHours(nextDate.getHours() + 1) + } + const currentHours = nextDate.getHours() + if (repeat.startHour < repeat.endHour) { + if (currentHours < repeat.startHour) { + nextDate.setHours(repeat.startHour) + } else if (currentHours > repeat.endHour) { + nextDate.setHours(repeat.startHour) + nextDate.setDate(nextDate.getDate() + 1) + } + } else { + if (currentHours > repeat.endHour && currentHours < repeat.startHour) { + nextDate.setHours(repeat.startHour) + } + } + break + } + case 'daily': { + const currentDay = nextDate.getDay() + const day = repeat.daysOfWeek.find((day) => day >= currentDay) ?? repeat.daysOfWeek[0] ?? 0 + const dayOffset = (day - currentDay + DAYS_PER_WEEK) % DAYS_PER_WEEK + nextDate.setDate(nextDate.getDate() + dayOffset) + break + } + case 'monthly-weekday': { + const currentDate = nextDate.getDate() + nextDate.setDate(1) + nextDate.setDate(1 + (repeat.weekNumber - 1) * DAYS_PER_WEEK) + const currentDay = nextDate.getDay() + const dayOffset = (repeat.dayOfWeek - currentDay + 7) % 7 + nextDate.setDate(nextDate.getDate() + dayOffset) + if (nextDate.getDate() < currentDate) { + nextDate.setDate(1) + nextDate.setMonth(nextDate.getMonth() + 1) + nextDate.setDate(1 + (repeat.weekNumber - 1) * DAYS_PER_WEEK) + const currentDay = nextDate.getDay() + const dayOffset = (repeat.dayOfWeek - currentDay + 7) % 7 + nextDate.setDate(nextDate.getDate() + dayOffset) + } + break + } + case 'monthly-date': { + const currentDate = nextDate.getDate() + const date = repeat.date + const goToNextMonth = date < currentDate + nextDate.setDate(date) + if (goToNextMonth) { + const startMonth = nextDate.getMonth() + nextDate.setMonth(startMonth + 1) + if ((nextDate.getMonth() + MONTHS_PER_YEAR - startMonth) % MONTHS_PER_YEAR > 1) { + nextDate.setDate(0) + } + } + break + } + } + switch (repeat.type) { + case 'hourly': + case 'daily': { + break + } + case 'monthly-date': + case 'monthly-weekday': { + const currentMonth = nextDate.getMonth() + const month = repeat.months.find((month) => month >= currentMonth) ?? repeat.months[0] ?? 0 + const monthOffset = (month - currentMonth + MONTHS_PER_YEAR) % MONTHS_PER_YEAR + nextDate.setMonth(nextDate.getMonth() + monthOffset) + } + } + return nextDate +} + +/** The next scheduled execution date of given {@link ProjectExecution}. */ +export function nextProjectExecutionDate(projectExecution: ProjectExecutionInfo, date: Date): Date { + // TODO: Account for timezone. + const nextDate = new Date(date) + const { repeat } = projectExecution + switch (repeat.type) { + case 'hourly': { + nextDate.setHours(nextDate.getHours() + 1) + const currentHours = nextDate.getHours() + if (repeat.startHour < repeat.endHour) { + if (currentHours < repeat.startHour) { + nextDate.setHours(repeat.startHour) + } else if (currentHours > repeat.endHour) { + nextDate.setDate(nextDate.getDate() + 1) + nextDate.setHours(repeat.startHour) + } + } else { + if (currentHours > repeat.endHour && currentHours < repeat.startHour) { + nextDate.setHours(repeat.startHour) + } + } + break + } + case 'daily': { + const currentDay = nextDate.getDay() + const day = repeat.daysOfWeek.find((day) => day > currentDay) ?? repeat.daysOfWeek[0] ?? 0 + const dayOffset = ((day - currentDay + 6) % 7) + 1 + nextDate.setDate(nextDate.getDate() + dayOffset) + break + } + case 'monthly-weekday': { + nextDate.setDate(1) + nextDate.setMonth(nextDate.getMonth() + 1) + nextDate.setDate(1 + (repeat.weekNumber - 1) * DAYS_PER_WEEK) + const currentDay = nextDate.getDay() + const dayOffset = ((repeat.dayOfWeek - currentDay + 6) % 7) + 1 + nextDate.setDate(nextDate.getDate() + dayOffset) + break + } + case 'monthly-date': { + const startMonth = nextDate.getMonth() + nextDate.setMonth(startMonth + 1) + if ((nextDate.getMonth() + MONTHS_PER_YEAR - startMonth) % MONTHS_PER_YEAR > 1) { + nextDate.setDate(0) + } + break + } + } + switch (repeat.type) { + case 'hourly': + case 'daily': { + break + } + case 'monthly-date': + case 'monthly-weekday': { + const currentMonth = nextDate.getMonth() + const month = repeat.months.find((month) => month >= currentMonth) ?? repeat.months[0] ?? 0 + const monthOffset = (month - currentMonth + MONTHS_PER_YEAR) % MONTHS_PER_YEAR + nextDate.setMonth(nextDate.getMonth() + monthOffset) + } + } + return nextDate +} + +/** + * All executions of the given {@link ProjectExecution} between the given dates. + * By default, return an empty array if the {@link ProjectExecution} repeats hourly. + * This is to prevent UI from being overly cluttered. + */ +export function getProjectExecutionRepetitionsForDateRange( + projectExecution: ProjectExecutionInfo, + startDate: Date, + endDate: Date, +): readonly Date[] { + const firstDate = firstProjectExecutionOnOrAfter(projectExecution, startDate) + if (firstDate >= endDate) { + return EMPTY_ARRAY + } + const repetitions: Date[] = [firstDate] + let currentDate = firstDate + currentDate = nextProjectExecutionDate(projectExecution, currentDate) + while (currentDate < endDate) { + repetitions.push(currentDate) + currentDate = nextProjectExecutionDate(projectExecution, currentDate) + } + return repetitions +} diff --git a/app/common/src/text.ts b/app/common/src/text.ts index 21007f9b5926..338311140895 100644 --- a/app/common/src/text.ts +++ b/app/common/src/text.ts @@ -71,45 +71,50 @@ interface PlaceholderOverrides { readonly removeUserFromUserGroupError: [userName: string, userGroupName: string] readonly deleteUserError: [userName: string] - readonly inviteUserBackendError: [string] - readonly changeUserGroupsBackendError: [string] - readonly listFolderBackendError: [string] - readonly createFolderBackendError: [string] - readonly updateFolderBackendError: [string] - readonly updateAssetBackendError: [string] - readonly deleteAssetBackendError: [string] - readonly undoDeleteAssetBackendError: [string] - readonly copyAssetBackendError: [string, string] - readonly createProjectBackendError: [string] - readonly restoreProjectBackendError: [string] - readonly duplicateProjectBackendError: [string] - readonly closeProjectBackendError: [string] - readonly listProjectSessionsBackendError: [string] - readonly getProjectLogsBackendError: [string] - readonly openProjectBackendError: [string] - readonly openProjectMissingCredentialsBackendError: [string] - readonly updateProjectBackendError: [string] - readonly checkResourcesBackendError: [string] - readonly uploadFileWithNameBackendError: [string] - readonly getFileDetailsBackendError: [string] - readonly createDatalinkBackendError: [string] - readonly getDatalinkBackendError: [string] - readonly deleteDatalinkBackendError: [string] - readonly createSecretBackendError: [string] - readonly getSecretBackendError: [string] - readonly updateSecretBackendError: [string] - readonly createLabelBackendError: [string] - readonly associateLabelsBackendError: [string] - readonly deleteLabelBackendError: [string] - readonly createUserGroupBackendError: [string] - readonly deleteUserGroupBackendError: [string] - readonly listVersionsBackendError: [string] - readonly createCheckoutSessionBackendError: [string] - readonly getCheckoutSessionBackendError: [string] - readonly getDefaultVersionBackendError: [string] - readonly logEventBackendError: [string] - - readonly subscribeSuccessSubtitle: [string] + readonly inviteUserBackendError: [userEmail: string] + readonly changeUserGroupsBackendError: [userName: string] + readonly listFolderBackendError: [folderTitle: string] + readonly createFolderBackendError: [folderTitle: string] + readonly updateFolderBackendError: [folderTitle: string] + readonly updateAssetBackendError: [assetTitle: string] + readonly deleteAssetBackendError: [assetTitle: string] + readonly undoDeleteAssetBackendError: [assetTitle: string] + readonly copyAssetBackendError: [assetTitle: string, newParentTitle: string] + readonly createProjectBackendError: [projectTitle: string] + readonly restoreProjectBackendError: [projectTitle: string] + readonly duplicateProjectBackendError: [projectTitle: string] + readonly closeProjectBackendError: [projectTitle: string] + readonly listProjectSessionsBackendError: [projectTitle: string] + readonly createProjectExecutionBackendError: [projectTitle: string] + readonly updateProjectExecutionBackendError: [projectTitle: string] + readonly deleteProjectExecutionBackendError: [projectTitle: string] + readonly listProjectExecutionsBackendError: [projectTitle: string] + readonly syncProjectExecutionBackendError: [projectTitle: string] + readonly getProjectLogsBackendError: [projectTitle: string] + readonly openProjectBackendError: [projectTitle: string] + readonly openProjectMissingCredentialsBackendError: [projectTitle: string] + readonly updateProjectBackendError: [projectTitle: string] + readonly checkResourcesBackendError: [projectTitle: string] + readonly uploadFileWithNameBackendError: [fileTitle: string] + readonly getFileDetailsBackendError: [fileTitle: string] + readonly createDatalinkBackendError: [datalinkTitle: string] + readonly getDatalinkBackendError: [datalinkTitle: string] + readonly deleteDatalinkBackendError: [datalinkTitle: string] + readonly createSecretBackendError: [secretTitle: string] + readonly getSecretBackendError: [secretTitle: string] + readonly updateSecretBackendError: [secretTitle: string] + readonly createLabelBackendError: [labelName: string] + readonly associateLabelsBackendError: [assetTitle: string] + readonly deleteLabelBackendError: [labelName: string] + readonly createUserGroupBackendError: [userGroupName: string] + readonly deleteUserGroupBackendError: [userGroupName: string] + readonly listVersionsBackendError: [versionType: string] + readonly createCheckoutSessionBackendError: [plan: string] + readonly getCheckoutSessionBackendError: [checkoutSessionId: string] + readonly getDefaultVersionBackendError: [versionType: string] + readonly logEventBackendError: [eventType: string] + + readonly subscribeSuccessSubtitle: [plan: string] readonly assetsDropFilesDescription: [count: number] readonly paywallAvailabilityLevel: [plan: string] @@ -133,10 +138,27 @@ interface PlaceholderOverrides { readonly tryFree: [days: number] readonly organizationNameSettingsInputDescription: [howLong: number] readonly trialDescription: [days: number] + + readonly repeatsAtX: [dates: string] + readonly xMinutes: [minutes: number] + readonly xAm: [hour: string] + readonly xPm: [hour: string] + readonly everyHourXMinute: [minute: string] readonly groupNameSettingsInputDescription: [howLong: number] readonly xIsUsingTheProject: [userName: string] readonly xItemsCopied: [count: number] readonly xItemsCut: [count: number] + readonly ordinalFallback: [number: number] + readonly dateXTimeX: [date: string, time: string] + readonly hourlyBetweenX: [startTime: string, endTime: string] + readonly projectSessionsOnX: [date: string] + readonly xthDayOfMonth: [dateOrdinal: string] + readonly xthXDayOfMonth: [weekOrdinal: string, dayOfWeek: string] + readonly lastXDayOfMonth: [dayOfWeek: string] + readonly repeatsTimeXMonthsXDateX: [time: string, months: string, date: string] + readonly repeatsTimeXMonthsXDayXWeekX: [time: string, months: string, day: string, week: string] + readonly repeatsTimeXMonthsXDayXLastWeek: [time: string, months: string, day: string] + readonly xthWeek: [weekOrdinal: string] readonly arbitraryFieldTooLarge: [maxSize: string] readonly arbitraryFieldTooSmall: [minSize: string] diff --git a/app/common/src/text/english.json b/app/common/src/text/english.json index 41d132f95426..544a2f4fcc23 100644 --- a/app/common/src/text/english.json +++ b/app/common/src/text/english.json @@ -1,6 +1,7 @@ { "submit": "Submit", "retry": "Retry", + "hide": "Hide", "arbitraryFetchError": "An error occurred while fetching data", "arbitraryFetchImageError": "An error occurred while fetching an image", @@ -116,7 +117,7 @@ "deleteUserBackendError": "Could not delete user", "removeUserBackendError": "Could not delete user", "uploadUserPictureBackendError": "Could not upload user profile picture", - "changeUserGroupsBackendError": "Could not change roles for user '$0'", + "changeUserGroupsBackendError": "Could not update list of teams for user '$0'", "getOrganizationBackendError": "Could not get organization", "updateOrganizationBackendError": "Could not update organization", "uploadOrganizationPictureBackendError": "Could not upload organization profile picture", @@ -142,6 +143,11 @@ "duplicateProjectBackendError": "Could not duplicate project as '$0'", "closeProjectBackendError": "Could not close project '$0'", "listProjectSessionsBackendError": "Could not list sessions for project '$0'", + "createProjectExecutionBackendError": "Could not create project execution for project '$0'", + "updateProjectExecutionBackendError": "Could not update project execution for project '$0'", + "deleteProjectExecutionBackendError": "Could not delete project execution for project '$0'", + "listProjectExecutionsBackendError": "Could not list project executions for project '$0'", + "syncProjectExecutionBackendError": "Could not sync project execution of project '$0'", "getProjectDetailsBackendError": "Could not get details of project", "getProjectLogsBackendError": "Could not get logs for project '$0'", "openProjectBackendError": "Could not open project '$0'", @@ -253,6 +259,8 @@ "drop": "Drop", "logs": "Logs", "showLogs": "Show Logs", + "executions": "Executions", + "executionsCalendar": "Executions Calendar", "accept": "Accept", "decline": "Decline", "clearTrash": "Clear Trash", @@ -426,6 +434,7 @@ "build": "Build", "errorColon": "Error: ", "developerInfo": "Dev mode info", + "ensoDevtools": "Enso Devtools", "electronVersion": "Electron", "chromeVersion": "Chrome", "userAgent": "User Agent", @@ -521,6 +530,7 @@ "deleteSelectedAssetsForeverActionText": "delete $0 selected items forever", "deleteUserActionText": "delete the user '$0'", "deleteUserGroupActionText": "delete the user group '$0'", + "deleteThisProjectExecution": "delete this project execution schedule", "removeUserFromUserGroupActionText": "remove the user '$0' from the user group '$1'", "enterTheNewKeyboardShortcutFor": "Enter the new keyboard shortcut for $0.", @@ -649,6 +659,84 @@ "adjustSeats": "Adjust seats", "billingPeriodOneYear": "1 year", "billingPeriodThreeYears": "3 years", + "new": "New", + "newProjectExecution": "New Schedule", + "repeatIntervalLabel": "Repeat", + "monthlyRepeatTypeLabel": "Monthly repeat type", + "firstOccurrenceLabel": "First occurrence", + "endAtLabel": "End at", + "parallelModeLabel": "Parallel mode", + "dateLabel": "Date", + "monthsLabel": "Months", + "daysLabel": "Days", + "startHourLabel": "Start hour", + "endHourLabel": "End hour", + "dayOfWeekLabel": "Day of week", + "weekOfMonthLabel": "Week of month", + "minuteLabel": "Minute", + "doesNotRepeat": "Does not repeat", + "hourly": "Hourly", + "daily": "Daily", + "xthDayOfMonth": "$0 of month", + "xthXDayOfMonth": "$0 $1 of month", + "lastXDayOfMonth": "Last $0 of month", + "repeatsTimeXMonthsXDateX": "$0 $1 $2", + "repeatsTimeXMonthsXDayXWeekX": "$0 $1 $2 $3", + "repeatsTimeXMonthsXDayXLastWeek": "$0 $1 last $2", + "ignoreParallelMode": "Ignore New", + "restartParallelMode": "Cancel Old", + "parallelParallelMode": "Run Both", + "advancedModeLabel": "Advanced mode", + "ignoreParallelModeDescription": "Do nothing when trying to start an execution while one is already running.", + "restartParallelModeDescription": "Stop the old execution before starting the new execution.", + "parallelParallelModeDescription": "Run the new execution as well as the old execution.", + "hourlyBetweenX": "Hourly between $0-$1", + "everyDaySuffix": "every day", + "everyMonth": "Every month", + "monday": "Monday", + "tuesday": "Tuesday", + "wednesday": "Wednesday", + "thursday": "Thursday", + "friday": "Friday", + "saturday": "Saturday", + "sunday": "Sunday", + "monday3": "Mon", + "tuesday3": "Tue", + "wednesday3": "Wed", + "thursday3": "Thu", + "friday3": "Fri", + "saturday3": "Sat", + "sunday3": "Sun", + "january3": "Jan", + "february3": "Feb", + "march3": "Mar", + "april3": "Apr", + "may3": "May", + "june3": "Jun", + "july3": "Jul", + "august3": "Aug", + "september3": "Sep", + "october3": "Oct", + "november3": "Nov", + "december3": "Dec", + "pluralOne": "st", + "pluralTwo": "nd", + "pluralFew": "rd", + "pluralOther": "th", + "pleaseSelectATime": "Please select a time.", + "repeatsAtX": "Repeats at $0", + "maxDurationLabel": "Maximum duration (minutes)", + "maxDurationMinutesLabel": "Maximum duration (minutes)", + "xMinutes": "$0 minute(s)", + "xAm": "$0 am", + "xPm": "$0 pm", + "dateXTimeX": "$0 $1", + "currentlyEnabledLabel": "Enabled (click to disable)", + "currentlyDisabledLabel": "Disabled (click to enable)", + "updateExecutionToLatestVersionLabel": "Use latest project version", + "projectSessionsOnX": "Project sessions on $0", + "noProjectSessions": "No project sessions.", + "noProjectExecutions": "Create an execution schedule using the button above.", "SLSA": "Enso Software License And Services Agreement", "slsaLicenseAgreementDescription1": "This Order is governed by the Software License and Service Agreement found at", "slsaLicenseAgreementDescription2": ", (the “Agreement”). All capitalized terms used in this Customer Order but not otherwise defined herein shall have the meanings set forth in the Agreement.\n Except as expressly provided in the Agreement, Products and Services purchased under this Customer Order are non-cancelable and non-refundable.", @@ -657,6 +745,12 @@ "xItemsCut": "$0 item(s) cut", "downloadToOpenWorkflow": "Download Enso to open workflows.", "downloadToExecuteWorkflow": "Download Enso to execute workflows.", + "advancedOptions": "Advanced options", + "noneProjectExecutionRepeatType": "No repeat", + "hourlyProjectExecutionRepeatType": "Hourly", + "dailyProjectExecutionRepeatType": "Daily", + "monthlyProjectExecutionRepeatType": "Monthly", + "xthWeek": "$0 week", "someAgreementsHaveBeenUpdated": "Some agreements have been updated. Please re-read and agree to continue.", "licenseAgreementTitle": "Enso Terms of Service", @@ -692,6 +786,8 @@ "rootFolderColumnName": "Root folder", "pathColumnName": "Location", + "hideDevtools": "Hide Devtools", + "ensoDevtoolsShortcut": "Enso Devtools", "settingsShortcut": "Settings", "closeTabShortcut": "Close Tab", "openShortcut": "Open", @@ -868,6 +964,7 @@ "userAccountSettingsSection": "User Account", "userNameSettingsInput": "Name", "userEmailSettingsInput": "Email", + "userTimeZoneSettingsInput": "Preferred time zone", "userCurrentPasswordSettingsInput": "Current password", "userNewPasswordSettingsInput": "New password", "userConfirmNewPasswordSettingsInput": "Confirm new password", @@ -992,7 +1089,13 @@ "assetVersions.localAssetsDoNotHaveVersions": "Local assets do not have versions.", "assetVersions.notSelected": "Select a single asset to view its versions.", "assetProjectSessions.noSessions": "No sessions yet! Open the project to start a session.", - "assetProjectSessions.notSelected": "Select a single project to view its sessions.", "assetProjectSessions.localBackend": "Sessions are not available for local projects.", - "assetProjectSessions.notProjectAsset": "Select a single project to view its sessions." + "assetProjectSessions.notSelected": "Select a single project to view its sessions.", + "assetProjectSessions.notProjectAsset": "Select a single project to view its sessions.", + "assetProjectExecutions.notSelected": "Select a single project to view its executions.", + "assetProjectExecutions.localBackend": "Executions are not available for local projects.", + "assetProjectExecutions.notProjectAsset": "Select a single project to view its executions.", + "assetProjectExecutionsCalendar.localBackend": "The execution calendar is not available for local projects.", + "assetProjectExecutionsCalendar.notSelected": "Select a single project to view its execution calendar.", + "assetProjectExecutionsCalendar.notProjectAsset": "Select a single project to view its execution calendar." } diff --git a/app/common/src/utilities/data/array.ts b/app/common/src/utilities/data/array.ts index f215269181e6..e0fc4a000f4c 100644 --- a/app/common/src/utilities/data/array.ts +++ b/app/common/src/utilities/data/array.ts @@ -19,8 +19,8 @@ export function shallowEqual(a: readonly T[], b: readonly T[]) { * Returns a type predicate that returns true if and only if the value is in the array. * The array MUST contain every element of `T`. */ -export function includes(array: T[], item: unknown): item is T { - const arrayOfUnknown: unknown[] = array +export function includes(array: readonly T[], item: unknown): item is T { + const arrayOfUnknown: readonly unknown[] = array return arrayOfUnknown.includes(item) } diff --git a/app/common/src/utilities/data/dateTime.ts b/app/common/src/utilities/data/dateTime.ts index 1653fb145c14..d4bd7dbe8607 100644 --- a/app/common/src/utilities/data/dateTime.ts +++ b/app/common/src/utilities/data/dateTime.ts @@ -1,12 +1,15 @@ /** @file Utilities for manipulating and displaying dates and times. */ -import * as newtype from './newtype' - -// ================= -// === Constants === -// ================= +import type { TextId } from '../../text' +import { type Newtype, newtypeConstructor } from './newtype' /** The number of hours in half a day. This is used to get the number of hours for AM/PM time. */ -const HALF_DAY_HOURS = 12 +export const HALF_DAY_HOURS = 12 +/** The number of milliseconds in one minute. */ +export const MINUTE_MS = 60_000 +export const MAX_DAYS_PER_MONTH = 31 +export const DAYS_PER_WEEK = 7 +export const HOURS_PER_DAY = 24 +export const HOUR_MINUTE = 60 /** A mapping from the month index returned by {@link Date.getMonth} to its full name. */ export const MONTH_NAMES = [ @@ -24,14 +27,45 @@ export const MONTH_NAMES = [ 'December', ] -// ================ -// === DateTime === -// ================ +export const DAY_3_LETTER_TEXT_IDS = [ + 'sunday3', + 'monday3', + 'tuesday3', + 'wednesday3', + 'thursday3', + 'friday3', + 'saturday3', +] satisfies TextId[] + +export const DAY_TEXT_IDS = [ + 'sunday', + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', +] satisfies TextId[] + +export const MONTH_3_LETTER_TEXT_IDS = [ + 'january3', + 'february3', + 'march3', + 'april3', + 'may3', + 'june3', + 'july3', + 'august3', + 'september3', + 'october3', + 'november3', + 'december3', +] satisfies TextId[] /** A string with date and time, following the RFC3339 specification. */ -export type Rfc3339DateTime = newtype.Newtype +export type Rfc3339DateTime = Newtype /** Create a {@link Rfc3339DateTime}. */ -export const Rfc3339DateTime = newtype.newtypeConstructor() +export const Rfc3339DateTime = newtypeConstructor() /** * Return a new {@link Date} with units below days (hours, minutes, seconds and milliseconds) @@ -76,3 +110,13 @@ export function formatDateTimeChatFriendly(date: Date) { export function toRfc3339(date: Date) { return Rfc3339DateTime(date.toISOString()) } + +/** Convert a UTC date to a local date. */ +export function localDateToUtcDate(date: Date) { + return new Date(Number(date) + date.getTimezoneOffset() * MINUTE_MS) +} + +/** Convert a local date to a UTC date. */ +export function utcDateToLocalDate(date: Date) { + return new Date(Number(date) - date.getTimezoneOffset() * MINUTE_MS) +} diff --git a/app/gui/integration-test/dashboard/actions/api.ts b/app/gui/integration-test/dashboard/actions/api.ts index 8f12be78c4a3..eb851271c17d 100644 --- a/app/gui/integration-test/dashboard/actions/api.ts +++ b/app/gui/integration-test/dashboard/actions/api.ts @@ -5,9 +5,9 @@ import * as backend from '#/services/Backend' import type * as remoteBackend from '#/services/RemoteBackend' import * as remoteBackendPaths from '#/services/remoteBackendPaths' -import * as dateTime from '#/utilities/dateTime' import * as object from '#/utilities/object' import * as permissions from '#/utilities/permissions' +import * as dateTime from 'enso-common/src/utilities/data/dateTime' import * as uniqueString from 'enso-common/src/utilities/uniqueString' import * as actions from '.' diff --git a/app/gui/integration-test/dashboard/sort.spec.ts b/app/gui/integration-test/dashboard/sort.spec.ts index c84711e904d9..aaadb9d8d983 100644 --- a/app/gui/integration-test/dashboard/sort.spec.ts +++ b/app/gui/integration-test/dashboard/sort.spec.ts @@ -1,7 +1,7 @@ /** @file Test sorting of assets columns. */ import { expect, test, type Locator } from '@playwright/test' -import { toRfc3339 } from '#/utilities/dateTime' +import { toRfc3339 } from 'enso-common/src/utilities/data/dateTime' import { mockAllAndLogin } from './actions' diff --git a/app/gui/src/dashboard/App.tsx b/app/gui/src/dashboard/App.tsx index ebe7cdbbef5a..89aeae155af9 100644 --- a/app/gui/src/dashboard/App.tsx +++ b/app/gui/src/dashboard/App.tsx @@ -73,7 +73,6 @@ import * as subscribeSuccess from '#/pages/subscribe/SubscribeSuccess' import * as openAppWatcher from '#/layouts/OpenAppWatcher' import VersionChecker from '#/layouts/VersionChecker' -import * as devtools from '#/components/Devtools' import * as errorBoundary from '#/components/ErrorBoundary' import * as suspense from '#/components/Suspense' import { RouterProvider } from 'react-aria-components' @@ -107,6 +106,7 @@ declare module '#/utilities/LocalStorage' { interface LocalStorageData { readonly inputBindings: Readonly> readonly localRootDirectory: string + readonly preferredTimeZone: string } } @@ -124,6 +124,7 @@ LocalStorage.registerKey('inputBindings', { }) LocalStorage.registerKey('localRootDirectory', { schema: z.string() }) +LocalStorage.registerKey('preferredTimeZone', { schema: z.string() }) // ====================== // === getMainPageUrl === @@ -542,11 +543,6 @@ function AppRouter(props: AppRouterProps) { {routes} - - - - - diff --git a/app/gui/src/dashboard/assets/arrow_left.svg b/app/gui/src/dashboard/assets/arrow_left.svg index 7c0b1e526cdf..b0d9e95c98c1 100644 --- a/app/gui/src/dashboard/assets/arrow_left.svg +++ b/app/gui/src/dashboard/assets/arrow_left.svg @@ -1,4 +1,4 @@ - diff --git a/app/gui/src/dashboard/assets/arrow_right.svg b/app/gui/src/dashboard/assets/arrow_right.svg index fbe6cc692291..5833c5ee9473 100644 --- a/app/gui/src/dashboard/assets/arrow_right.svg +++ b/app/gui/src/dashboard/assets/arrow_right.svg @@ -1,4 +1,4 @@ - diff --git a/app/gui/src/dashboard/assets/arrows_repeat.svg b/app/gui/src/dashboard/assets/arrows_repeat.svg new file mode 100644 index 000000000000..afe72706081d --- /dev/null +++ b/app/gui/src/dashboard/assets/arrows_repeat.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/gui/src/dashboard/assets/calendar.svg b/app/gui/src/dashboard/assets/calendar.svg new file mode 100644 index 000000000000..6fc8980aa9d3 --- /dev/null +++ b/app/gui/src/dashboard/assets/calendar.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/app/gui/src/dashboard/assets/calendar_repeat_outline.svg b/app/gui/src/dashboard/assets/calendar_repeat_outline.svg new file mode 100644 index 000000000000..593fd83f7865 --- /dev/null +++ b/app/gui/src/dashboard/assets/calendar_repeat_outline.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/gui/src/dashboard/assets/parallel.svg b/app/gui/src/dashboard/assets/parallel.svg new file mode 100644 index 000000000000..06702a349abe --- /dev/null +++ b/app/gui/src/dashboard/assets/parallel.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/gui/src/dashboard/assets/repeat.svg b/app/gui/src/dashboard/assets/repeat.svg new file mode 100644 index 000000000000..a43e1bda6c0d --- /dev/null +++ b/app/gui/src/dashboard/assets/repeat.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/gui/src/dashboard/assets/stop.svg b/app/gui/src/dashboard/assets/stop.svg index ce304e98f93d..36990b0045a4 100644 --- a/app/gui/src/dashboard/assets/stop.svg +++ b/app/gui/src/dashboard/assets/stop.svg @@ -1,4 +1,4 @@ - + \ No newline at end of file diff --git a/app/gui/src/dashboard/assets/stop2.svg b/app/gui/src/dashboard/assets/stop2.svg new file mode 100644 index 000000000000..a288200bcf2b --- /dev/null +++ b/app/gui/src/dashboard/assets/stop2.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/app/gui/src/dashboard/assets/upgrade.svg b/app/gui/src/dashboard/assets/upgrade.svg new file mode 100644 index 000000000000..5a8a5bc9eb94 --- /dev/null +++ b/app/gui/src/dashboard/assets/upgrade.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/gui/src/dashboard/authentication/cognito.ts b/app/gui/src/dashboard/authentication/cognito.ts index 266f01eb66fc..806ba41bd4cc 100644 --- a/app/gui/src/dashboard/authentication/cognito.ts +++ b/app/gui/src/dashboard/authentication/cognito.ts @@ -39,8 +39,8 @@ import * as detect from 'enso-common/src/detect' import type * as loggerProvider from '#/providers/LoggerProvider' -import * as dateTime from '#/utilities/dateTime' import type * as saveAccessToken from 'enso-common/src/accessToken' +import * as dateTime from 'enso-common/src/utilities/data/dateTime' import * as service from '#/authentication/service' diff --git a/app/gui/src/dashboard/components/AnimatedBackground.tsx b/app/gui/src/dashboard/components/AnimatedBackground.tsx index 02152d0d2397..4b00d2fa1759 100644 --- a/app/gui/src/dashboard/components/AnimatedBackground.tsx +++ b/app/gui/src/dashboard/components/AnimatedBackground.tsx @@ -1,7 +1,6 @@ /** * @file - * - * `` component visually highlights selected items by sliding a background into view when hovered over or clicked. + * Visually highlight selected items by sliding a background into view when hovered over or clicked. */ import type { Transition, Variants } from 'framer-motion' import { AnimatePresence, motion } from 'framer-motion' @@ -56,9 +55,7 @@ export function AnimatedBackground(props: AnimatedBackgroundProps) { ) } -/** - * Props for {@link AnimatedBackground.Item}. - */ +/** Props for {@link AnimatedBackground.Item}. */ type AnimatedBackgroundItemProps = PropsWithChildren< AnimatedBackgroundItemPropsWithSelected | AnimatedBackgroundItemPropsWithValue > & { @@ -67,17 +64,13 @@ type AnimatedBackgroundItemProps = PropsWithChildren< readonly underlayElement?: React.ReactNode } -/** - * Props for {@link AnimatedBackground.Item} with a `value` prop. - */ +/** Props for {@link AnimatedBackground.Item} with a `value` prop. */ interface AnimatedBackgroundItemPropsWithValue { readonly value: string readonly isSelected?: never } -/** - * Props for {@link AnimatedBackground.Item} with a `isSelected` prop. - */ +/** Props for {@link AnimatedBackground.Item} with a `isSelected` prop. */ interface AnimatedBackgroundItemPropsWithSelected { readonly isSelected: boolean readonly value?: never @@ -126,9 +119,7 @@ AnimatedBackground.Item = memo(function AnimatedBackgroundItem(props: AnimatedBa ) }) -/** - * Props for {@link AnimatedBackgroundItemUnderlay}. - */ +/** Props for {@link AnimatedBackgroundItemUnderlay}. */ interface AnimatedBackgroundItemUnderlayProps { readonly isActive: boolean readonly underlayElement: React.ReactNode @@ -141,9 +132,7 @@ const VARIANTS: Variants = { visible: { opacity: 1 }, } -/** - * Underlay for {@link AnimatedBackground.Item}. - */ +/** Underlay for {@link AnimatedBackground.Item}. */ // eslint-disable-next-line no-restricted-syntax const AnimatedBackgroundItemUnderlay = memo(function AnimatedBackgroundItemUnderlay( props: AnimatedBackgroundItemUnderlayProps, diff --git a/app/gui/src/dashboard/components/AriaComponents/Checkbox/Checkbox.tsx b/app/gui/src/dashboard/components/AriaComponents/Checkbox/Checkbox.tsx index 10b26b93c3a4..180eb5626fa3 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Checkbox/Checkbox.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Checkbox/Checkbox.tsx @@ -36,10 +36,10 @@ import { CheckboxStandaloneProvider, useCheckboxContext } from './CheckboxContex import { CheckboxGroup } from './CheckboxGroup' /** Props for the {@link Checkbox} component. */ -export type CheckboxProps> = Omit< - VariantProps, - 'isDisabled' | 'isInvalid' -> & +export type CheckboxProps< + Schema extends TSchema, + TFieldName extends FieldPath, +> = Omit, 'isDisabled' | 'isInvalid'> & TestIdProps & { readonly className?: string readonly style?: CSSProperties @@ -56,8 +56,8 @@ interface CheckboxGroupCheckboxProps extends AriaCheckboxProps { /** Props for the {@link Checkbox} component when used outside of a {@link CheckboxGroup}. */ type StandaloneCheckboxProps< Schema extends TSchema, - TFieldName extends FieldPath, -> = FieldProps & FieldStateProps & FieldVariantProps + TFieldName extends FieldPath, +> = FieldProps & FieldStateProps & FieldVariantProps export const CHECKBOX_STYLES = tv({ base: 'group flex gap-2 items-center cursor-pointer select-none', @@ -108,7 +108,7 @@ export const CHECKBOX_STYLES = tv({ // eslint-disable-next-line no-restricted-syntax export const Checkbox = forwardRef(function Checkbox< Schema extends TSchema, - TFieldName extends FieldPath, + TFieldName extends FieldPath, >(props: CheckboxProps, ref: ForwardedRef) { const { form, name } = props @@ -173,7 +173,7 @@ export const Checkbox = forwardRef(function Checkbox< } return -}) as unknown as (>( +}) as unknown as (>( props: CheckboxProps & RefAttributes, ) => ReactElement) & { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -183,17 +183,17 @@ export const Checkbox = forwardRef(function Checkbox< /** * Internal props for the {@link Checkbox} component. */ -type CheckboxInternalProps> = Omit< - CheckboxProps, - 'name' -> & { +type CheckboxInternalProps< + Schema extends TSchema, + TFieldName extends FieldPath, +> = Omit, 'name'> & { name?: string } // eslint-disable-next-line no-restricted-syntax const CheckboxInternal = forwardRef(function CheckboxInternal< Schema extends TSchema, - TFieldName extends FieldPath, + TFieldName extends FieldPath, >(props: CheckboxInternalProps, ref: ForwardedRef) { const { variants = CHECKBOX_STYLES, @@ -228,7 +228,7 @@ const CheckboxInternal = forwardRef(function CheckboxInternal< // This is safe, because the name is handled by the `CheckboxGroup` component // and checked there // eslint-disable-next-line no-restricted-syntax - field: state.field as UseFormRegisterReturn, + field: state.field as UseFormRegisterReturn, // eslint-disable-next-line no-restricted-syntax name: state.name as TFieldName, onChange: (checked: boolean) => { @@ -306,7 +306,7 @@ const CheckboxInternal = forwardRef(function CheckboxInternal< )} ) -}) as unknown as (>( +}) as unknown as (>( props: CheckboxInternalProps & RefAttributes, ) => ReactElement) & { // eslint-disable-next-line @typescript-eslint/naming-convention diff --git a/app/gui/src/dashboard/components/AriaComponents/Checkbox/CheckboxGroup.tsx b/app/gui/src/dashboard/components/AriaComponents/Checkbox/CheckboxGroup.tsx index 43dffd4e0584..e598cf2ee2bc 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Checkbox/CheckboxGroup.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Checkbox/CheckboxGroup.tsx @@ -1,8 +1,4 @@ -/** - * @file - * - * A CheckboxGroup allows users to select one or more items from a list of choices. - */ +/** @file A selector for one or more items from a list of choices. */ import type { CheckboxGroupProps as AriaCheckboxGroupProps } from '#/components/aria' import { CheckboxGroup as AriaCheckboxGroup, mergeProps } from '#/components/aria' import { mergeRefs } from '#/utilities/mergeRefs' @@ -16,9 +12,11 @@ import { Form, type FieldPath, type FieldProps, type FieldStateProps, type TSche import type { TestIdProps } from '../types' import { CheckboxGroupProvider } from './CheckboxContext' -/** Props for the {@link CheckboxGroupProps} component. */ -export interface CheckboxGroupProps> - extends FieldStateProps, +/** Props for the {@link CheckboxGroup} component. */ +export interface CheckboxGroupProps< + Schema extends TSchema, + TFieldName extends FieldPath, +> extends FieldStateProps, FieldProps, FieldVariantProps, Omit, 'disabled' | 'invalid'>, @@ -34,9 +32,9 @@ const CHECKBOX_GROUP_STYLES = tv({ variants: { fullWidth: { true: 'w-full' } }, }) -/** A CheckboxGroup allows users to select one or more items from a list of choices. */ +/** A selector for one or more items from a list of choices. */ export const CheckboxGroup = forwardRef( - >( + >( props: CheckboxGroupProps, ref: ForwardedRef, ): ReactElement => { diff --git a/app/gui/src/dashboard/components/AriaComponents/Dialog/Dialog.tsx b/app/gui/src/dashboard/components/AriaComponents/Dialog/Dialog.tsx index d393a64dbc28..8f0a9aaf5574 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Dialog/Dialog.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Dialog/Dialog.tsx @@ -15,7 +15,7 @@ import * as mergeRefs from '#/utilities/mergeRefs' import { DialogDismiss } from '#/components/AriaComponents' import { useEventCallback } from '#/hooks/eventCallbackHooks' import { useMeasure } from '#/hooks/measureHooks' -import { motion, type Spring } from '#/utilities/motion' +import { LayoutGroup, motion, type Spring } from '#/utilities/motion' import type { VariantProps } from '#/utilities/tailwindVariants' import { tv } from '#/utilities/tailwindVariants' import { Close } from './Close' @@ -322,7 +322,7 @@ function DialogContent(props: DialogContentProps) { } return ( - <> + - + ) } diff --git a/app/gui/src/dashboard/components/AriaComponents/Form/Form.tsx b/app/gui/src/dashboard/components/AriaComponents/Form/Form.tsx index 968674b6609b..e0207da7ee2a 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Form/Form.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Form/Form.tsx @@ -125,6 +125,7 @@ export const Form = forwardRef(function Form< schema: typeof components.schema useForm: typeof components.useForm useField: typeof components.useField + makeUseField: typeof components.makeUseField Submit: typeof components.Submit Reset: typeof components.Reset Field: typeof components.Field @@ -146,6 +147,7 @@ export const Form = forwardRef(function Form< Form.schema = components.schema Form.useForm = components.useForm Form.useField = components.useField +Form.makeUseField = components.makeUseField Form.useFormSchema = components.useFormSchema Form.Submit = components.Submit Form.Reset = components.Reset diff --git a/app/gui/src/dashboard/components/AriaComponents/Form/components/Field.tsx b/app/gui/src/dashboard/components/AriaComponents/Form/components/Field.tsx index f552e830c704..85c49a1719c0 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Form/components/Field.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Form/components/Field.tsx @@ -7,9 +7,9 @@ import * as React from 'react' import * as aria from '#/components/aria' +import type { Path } from '#/utilities/objectPath' import { forwardRef } from '#/utilities/react' import { tv, type VariantProps } from '#/utilities/tailwindVariants' -import type { Path } from 'react-hook-form' import * as text from '../../Text' import { Form } from '../Form' import type * as types from './types' @@ -19,7 +19,8 @@ export interface FieldComponentProps extends VariantProps, types.FieldProps { readonly 'data-testid'?: string | undefined - readonly name: Path> + // eslint-disable-next-line @typescript-eslint/no-explicit-any + readonly name: Path, any> readonly form?: types.FormInstance | undefined readonly isInvalid?: boolean | undefined readonly className?: string | undefined @@ -82,7 +83,9 @@ export const Field = forwardRef(function Field( const descriptionId = React.useId() const errorId = React.useId() - const fieldState = Form.useFieldState(props) + // This is SAFE, we are just using a type with added constraint. + // eslint-disable-next-line no-restricted-syntax + const fieldState = Form.useFieldState(props as never) const invalid = isInvalid || fieldState.hasError diff --git a/app/gui/src/dashboard/components/AriaComponents/Form/components/FieldValue.tsx b/app/gui/src/dashboard/components/AriaComponents/Form/components/FieldValue.tsx index 6f73cb8acd98..8adfbf70b9df 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Form/components/FieldValue.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Form/components/FieldValue.tsx @@ -10,7 +10,11 @@ import type { FieldPath, FieldValues, FormInstanceValidated, TSchema } from './t /** * Props for the {@link FieldValue} component. */ -export interface FieldValueProps> { +export interface FieldValueProps< + Schema extends TSchema, + TFieldName extends FieldPath, + Constraint, +> { readonly form?: FormInstanceValidated readonly name: TFieldName readonly children: (value: FieldValues[TFieldName]) => ReactNode @@ -20,9 +24,11 @@ export interface FieldValueProps>( - props: FieldValueProps, -) { +export function FieldValue< + Schema extends TSchema, + TFieldName extends FieldPath, + Constraint, +>(props: FieldValueProps) { const { form, name, children, disabled = false } = props const formInstance = useFormContext(form) diff --git a/app/gui/src/dashboard/components/AriaComponents/Form/components/types.ts b/app/gui/src/dashboard/components/AriaComponents/Form/components/types.ts index fc9fe23c35c0..87873679ab35 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Form/components/types.ts +++ b/app/gui/src/dashboard/components/AriaComponents/Form/components/types.ts @@ -7,6 +7,7 @@ import type * as React from 'react' import type * as reactHookForm from 'react-hook-form' import type * as z from 'zod' +import type { Path } from '#/utilities/objectPath' import type { FormEvent } from 'react' import type * as schemaModule from './schema' @@ -22,7 +23,10 @@ export type TransformedValues = * Field path type. * @alias reactHookForm.FieldPath */ -export type FieldPath = reactHookForm.FieldPath> +export type FieldPath = Extract< + Path, Constraint>, + reactHookForm.FieldPath> +> /** Schema type */ export type TSchema = @@ -40,7 +44,7 @@ export type SchemaBuilder = typeof schemaModule.schema export interface OnSubmitCallbacks { readonly onSubmit?: | (( - values: FieldValues, + values: TransformedValues, form: UseFormReturn, ) => Promise | SubmitResult) | undefined @@ -48,14 +52,14 @@ export interface OnSubmitCallbacks readonly onSubmitFailed?: | (( error: unknown, - values: FieldValues, + values: TransformedValues, form: UseFormReturn, ) => Promise | void) | undefined readonly onSubmitSuccess?: | (( data: SubmitResult, - values: FieldValues, + values: TransformedValues, form: UseFormReturn, ) => Promise | void) | undefined @@ -63,7 +67,7 @@ export interface OnSubmitCallbacks | (( data: SubmitResult | undefined, error: unknown, - values: FieldValues, + values: TransformedValues, form: UseFormReturn, ) => Promise | void) | undefined @@ -85,12 +89,15 @@ export interface UseFormOptions /** Debug name for the form. Use it to identify the form in the tanstack query devtools. */ readonly debugName?: string + + /** When set to `dialog`, form submission will close the parent dialog on successful submission. */ readonly method?: 'dialog' | (string & {}) | undefined } /** Register function for a form field. */ export type UseFormRegister = < - TFieldName extends FieldPath = FieldPath, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + TFieldName extends FieldPath = FieldPath, >( name: TFieldName, options?: reactHookForm.RegisterOptions, TFieldName>, @@ -117,6 +124,8 @@ export interface UseFormRegisterReturn< * Return type of the useForm hook. * @alias reactHookForm.UseFormReturn */ +// @ts-expect-error This is type-safe, we are just using a narrower definition of `FieldPath` in +// `UseFormRegister`. export interface UseFormReturn extends Omit< reactHookForm.UseFormReturn, unknown, TransformedValues>, @@ -149,7 +158,8 @@ export type FormInstance = UseFormReturn export interface FormWithValueValidation< BaseValueType, Schema extends TSchema, - TFieldName extends FieldPath, + TFieldName extends FieldPath, + Constraint, // It is not ideal to have this as a parameter as it can be edited, but this is the simplest way // to avoid distributive conditional types to affect the error message. We want distributivity // to happen, just not for the error message itself. @@ -214,8 +224,9 @@ export interface FieldProps { export interface FormFieldProps< BaseValueType, Schema extends TSchema, - TFieldName extends FieldPath, -> extends FormWithValueValidation { + TFieldName extends FieldPath, + Constraint, +> extends FormWithValueValidation { readonly name: TFieldName readonly value?: BaseValueType extends FieldValues ? FieldValues[TFieldName] : never @@ -229,11 +240,12 @@ export interface FormFieldProps< export type FieldStateProps< BaseProps extends { value?: unknown }, Schema extends TSchema, - TFieldName extends FieldPath, -> = FormFieldProps & { + TFieldName extends FieldPath, + Constraint, +> = FormFieldProps & { // to avoid conflicts with the FormFieldProps we need to omit the FormFieldProps from the BaseProps [K in keyof Omit< BaseProps, - keyof FormFieldProps + keyof FormFieldProps >]: BaseProps[K] } diff --git a/app/gui/src/dashboard/components/AriaComponents/Form/components/useField.ts b/app/gui/src/dashboard/components/AriaComponents/Form/components/useField.ts index 371c1ce6cfbb..2ea93e4693a4 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Form/components/useField.ts +++ b/app/gui/src/dashboard/components/AriaComponents/Form/components/useField.ts @@ -3,33 +3,35 @@ * * A hook for creating a field and field state for a form. */ -import * as reactHookForm from 'react-hook-form' +import { useController } from 'react-hook-form' -import * as formContext from './FormProvider' -import type * as types from './types' +import { useFormContext } from './FormProvider' +import type { FieldPath, FieldValues, FormWithValueValidation, TSchema } from './types' /** Options for {@link useField} hook. */ export interface UseFieldOptions< BaseValueType, - Schema extends types.TSchema, - TFieldName extends types.FieldPath, -> extends types.FormWithValueValidation { + Schema extends TSchema, + TFieldName extends FieldPath, + Constraint, +> extends FormWithValueValidation { readonly name: TFieldName readonly isDisabled?: boolean | undefined - readonly defaultValue?: types.FieldValues[TFieldName] | undefined + readonly defaultValue?: FieldValues[TFieldName] | undefined } /** A hook that connects a field to a form state. */ export function useField< BaseValueType, - Schema extends types.TSchema, - TFieldName extends types.FieldPath, ->(options: UseFieldOptions) { + Schema extends TSchema, + TFieldName extends FieldPath, + Constraint, +>(options: UseFieldOptions) { const { name, defaultValue, isDisabled = false } = options - const formInstance = formContext.useFormContext(options.form) + const formInstance = useFormContext(options.form) - const { field, fieldState, formState } = reactHookForm.useController({ + const { field, fieldState, formState } = useController({ name, disabled: isDisabled, control: formInstance.control, @@ -38,3 +40,16 @@ export function useField< return { field, fieldState, formState, formInstance } as const } + +/** + * A hook that connects a field to a form state. + */ +export function makeUseField() { + return function useFieldWithConstraint< + BaseValueType, + Schema extends TSchema, + TFieldName extends FieldPath, + >(options: UseFieldOptions) { + return useField(options) + } +} diff --git a/app/gui/src/dashboard/components/AriaComponents/Form/components/useFieldRegister.ts b/app/gui/src/dashboard/components/AriaComponents/Form/components/useFieldRegister.ts index 8a2015bff8b2..8972cf62ef2d 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Form/components/useFieldRegister.ts +++ b/app/gui/src/dashboard/components/AriaComponents/Form/components/useFieldRegister.ts @@ -17,8 +17,9 @@ import type { export type UseFieldRegisterOptions< BaseValueType extends { value?: unknown }, Schema extends TSchema, - TFieldName extends FieldPath, -> = Omit, 'form'> & { + TFieldName extends FieldPath, + Constraint, +> = Omit, 'form'> & { name: TFieldName form?: FormInstanceValidated | undefined defaultValue?: FieldValues[TFieldName] | undefined @@ -33,16 +34,19 @@ export type UseFieldRegisterOptions< export function useFieldRegister< BaseValueType extends { value?: unknown }, Schema extends TSchema, - TFieldName extends FieldPath, ->(options: UseFieldRegisterOptions) { + TFieldName extends FieldPath, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Constraint = any, +>(options: UseFieldRegisterOptions) { const { name, min, max, minLength, maxLength, isRequired, isDisabled, form, setValueAs } = options const formInstance = useFormContext(form) - const extractedValidationDetails = unsafe__extractValidationDetailsFromSchema( - formInstance.schema, - name, - ) + const extractedValidationDetails = unsafe__extractValidationDetailsFromSchema< + Schema, + TFieldName, + Constraint + >(formInstance.schema, name) const fieldProps = formInstance.register(name, { disabled: isDisabled ?? false, @@ -63,7 +67,8 @@ export function useFieldRegister< // eslint-disable-next-line camelcase, @typescript-eslint/naming-convention function unsafe__extractValidationDetailsFromSchema< Schema extends TSchema, - TFieldName extends FieldPath, + TFieldName extends FieldPath, + Constraint, >(schema: Schema, name: TFieldName) { try { if ('shape' in schema) { diff --git a/app/gui/src/dashboard/components/AriaComponents/Form/components/useFieldState.ts b/app/gui/src/dashboard/components/AriaComponents/Form/components/useFieldState.ts index 3b91c4a7623f..e6eced64afb7 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Form/components/useFieldState.ts +++ b/app/gui/src/dashboard/components/AriaComponents/Form/components/useFieldState.ts @@ -1,24 +1,24 @@ -/** - * @file - * - * Hook to get the state of a field. - */ +/** @file Hook to get the state of a field. */ import { useFormContext } from './FormProvider' import type { FieldPath, FormInstanceValidated, TSchema } from './types' /** Options for the `useFieldState` hook. */ export interface UseFieldStateOptions< Schema extends TSchema, - TFieldName extends FieldPath, + TFieldName extends FieldPath, + Constraint, > { readonly name: TFieldName readonly form?: FormInstanceValidated | undefined } /** Hook to get the state of a field. */ -export function useFieldState>( - options: UseFieldStateOptions, -) { +export function useFieldState< + Schema extends TSchema, + TFieldName extends FieldPath, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Constraint = any, +>(options: UseFieldStateOptions) { const { name } = options const form = useFormContext(options.form) diff --git a/app/gui/src/dashboard/components/AriaComponents/Form/components/useForm.ts b/app/gui/src/dashboard/components/AriaComponents/Form/components/useForm.ts index 0b26108fce03..9ca3a93bb099 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Form/components/useForm.ts +++ b/app/gui/src/dashboard/components/AriaComponents/Form/components/useForm.ts @@ -63,6 +63,7 @@ export function useForm( return optionsOrFormInstance } else { const { + method, schema, onSubmit, canSubmitOffline = false, @@ -70,7 +71,6 @@ export function useForm( onSubmitted, onSubmitSuccess, debugName, - method, ...options } = optionsOrFormInstance @@ -241,6 +241,8 @@ export function useForm( const form: types.UseFormReturn = { ...formInstance, submit, + // @ts-expect-error Our `UseFormRegister` is the same as `react-hook-form`'s, + // just with an added constraint. control: { ...formInstance.control, register }, register, schema: computedSchema, diff --git a/app/gui/src/dashboard/components/AriaComponents/Form/types.ts b/app/gui/src/dashboard/components/AriaComponents/Form/types.ts index fe9bd699a3eb..2be20db98011 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Form/types.ts +++ b/app/gui/src/dashboard/components/AriaComponents/Form/types.ts @@ -96,7 +96,9 @@ export type UseFormRegister = < /** UseFormRegister return type. */ export interface UseFormRegisterReturn< Schema extends components.TSchema, - TFieldName extends components.FieldPath = components.FieldPath, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + TFieldName extends components.FieldPath = components.FieldPath, + Constraint = unknown, > extends Omit, 'onBlur' | 'onChange'> { // eslint-disable-next-line @typescript-eslint/no-invalid-void-type readonly onChange: (value: Value) => Promise | void diff --git a/app/gui/src/dashboard/components/AriaComponents/Inputs/ComboBox/ComboBox.tsx b/app/gui/src/dashboard/components/AriaComponents/Inputs/ComboBox/ComboBox.tsx index 37d78b50544b..6228c142b6d5 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Inputs/ComboBox/ComboBox.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Inputs/ComboBox/ComboBox.tsx @@ -48,29 +48,34 @@ const COMBO_BOX_STYLES = tv({ }) /** Props for a {@link ComboBox}. */ -export interface ComboBoxProps> +export interface ComboBoxProps> extends FieldStateProps< Omit< AriaComboBoxProps[TFieldName]>, 'children' | 'className' | 'style' > & { value?: FieldValues[TFieldName] }, Schema, - TFieldName + TFieldName, + string >, FieldProps, Pick, 'className' | 'style'>, VariantProps, - Pick, 'placeholder'> { + Pick, 'placeholder'> { /** This may change as the user types in the input. */ readonly items: readonly FieldValues[TFieldName][] readonly children: (item: FieldValues[TFieldName]) => string readonly noResetButton?: boolean } +// This is a function, even though it does not contain function syntax. +// eslint-disable-next-line no-restricted-syntax +const useStringField = Form.makeUseField() + /** A combo box with a list of items that can be filtered. */ export const ComboBox = forwardRef(function ComboBox< Schema extends TSchema, - TFieldName extends FieldPath, + TFieldName extends FieldPath, >(props: ComboBoxProps, ref: ForwardedRef) { const { name, @@ -92,7 +97,7 @@ export const ComboBox = forwardRef(function ComboBox< [items, itemsAreStrings], ) - const { fieldState, formInstance } = Form.useField({ + const { fieldState, formInstance } = useStringField({ name, isDisabled, form, diff --git a/app/gui/src/dashboard/components/AriaComponents/Inputs/DatePicker/DatePicker.tsx b/app/gui/src/dashboard/components/AriaComponents/Inputs/DatePicker/DatePicker.tsx index ed0283a84935..18a3189d54b5 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Inputs/DatePicker/DatePicker.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Inputs/DatePicker/DatePicker.tsx @@ -57,8 +57,8 @@ const DATE_PICKER_STYLES = tv({ dateInput: 'flex justify-center grow', dateSegment: 'rounded placeholder-shown:text-primary/30 focus:bg-primary/10 px-[0.5px]', resetButton: '', - calendarPopover: 'w-0', - calendarDialog: 'text-primary text-xs', + calendarPopover: '', + calendarDialog: 'text-primary text-xs mx-2', calendarContainer: '', calendarHeader: 'flex items-center mb-2', calendarHeading: 'grow text-center', @@ -75,14 +75,18 @@ const DATE_PICKER_STYLES = tv({ }) /** Props for a {@link DatePicker}. */ -export interface DatePickerProps> - extends FieldStateProps< +export interface DatePickerProps< + Schema extends TSchema, + TFieldName extends FieldPath, +> extends Pick, 'granularity'>, + FieldStateProps< Omit< AriaDatePickerProps[TFieldName], DateValue>>, 'children' | 'className' | 'style' >, Schema, - TFieldName + TFieldName, + DateValue >, FieldProps, Pick, 'className' | 'style'>, @@ -92,13 +96,18 @@ export interface DatePickerProps> } +// This is a function, even though it does not contain function syntax. +// eslint-disable-next-line no-restricted-syntax +const useDateValueField = Form.makeUseField() + /** A date picker. */ export const DatePicker = forwardRef(function DatePicker< Schema extends TSchema, - TFieldName extends FieldPath, + TFieldName extends FieldPath, >(props: DatePickerProps, ref: ForwardedRef) { const { - noResetButton = false, + isRequired = false, + noResetButton = isRequired, noCalendarHeader = false, segments = {}, name, @@ -106,13 +115,13 @@ export const DatePicker = forwardRef(function DatePicker< form, defaultValue, label, - isRequired, className, size, variants = DATE_PICKER_STYLES, + granularity, } = props - const { fieldState, formInstance } = Form.useField({ + const { fieldState, formInstance } = useDateValueField({ name, isDisabled, form, @@ -141,7 +150,11 @@ export const DatePicker = forwardRef(function DatePicker< name={name} render={(renderProps) => { return ( - +