From d41de5579c7602442339d24c0eaa6a284d15bd8d Mon Sep 17 00:00:00 2001 From: Vladimir Vuckovic <37671301+stamenione@users.noreply.github.com> Date: Fri, 26 Apr 2024 16:11:08 +0200 Subject: [PATCH] Admin UI: Overview of all identities (#593) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add data table 2 * Admin UI: Login page (#584) * chore: adapt the header appearance * chore: add new design for login box * chore: adapt login position in padding widget via screen size * chore: logic for displaying the error message for attempted login * chore: remove error message * chore: add error message * chore: change the button colors and add a text button * chore: remove Visibility widget * chore: add text style for text button * chore: change todo comment according to the flutter style * refactor: extract the app title to be a separate widget * refactor: extract text field to be reusable * chore: use new app title widget * chore: use custom text field * chore: make the text field fixed height * chore: rename variables and make them private * chore: update imports * chore: use extracted app title widget * chore: remove unnecessary widgets and center the card * chore: add custom colors and move login button to the bottom * chore: remove unused import * refactor: rename folder and files appropriately * refactor: extract sized box into a separate custom widget * refactor: extract elevated button into a separate custom widget * chore: make the variable private * chore: local variable should not start with underscore * chore: add DI for baseUrl * fix: untangle coding * fix: imports * fix: make CustomColors easier accessible * fix: make prettier * feat: add gaps * fix: rename file * refactor: remove CustomX, make whole logic with one variable * fix: bool logic * ci: add jkoenig134 as codeowner * chore: remove accidentally committet code * fix: undo change * refactor: simplify login screen * refactor: simplify app title * refactor: PR comments * chore: inline svg picture * chore: make multi line params last --------- Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> Co-authored-by: Julian König Co-authored-by: Timo Notheisen * feat: add multiselect * feat: add data table 2 and multi dropdown * fix: remove dependencies from wrong pubspec * fix: remove filter when there are no filters applied * chore: add builders * chore: display the IdentityOverview * fix: change the widget * feat: add identity overview * feat: add components * feat: add shared filter fields * chore: tier overview (wip) * chore: identity details (wip) * chore: pagination (wip) * chore: update rowCount * fix: pagination * fix: pagination * chore: remove comment and print * chore: remove comments * fix: revert created files * chore: use switch expression * chore: use gaps 16 and enlarge the textfield to 120 * chore: remove unused controller * chore: use async paginated data table 2 * chore: rename variables and add this.* * chore: change the named parameter * chore: change the color depending of a theme mode * chore: rename a label * chore: remove comment * chore: move files into a new folder * chore: remove components.dart and change the import * chore: move the file in the new folder and implement async data table source * chore: add trailing commas * refactor: change code structure / remove empty widget * chore: add logger * fix: data loading * chore: make selected date nullable and remove isDateSelected * chore: make variables private and remove late * fix: overhaul sorting and filtering * fix: add empty hint * fix: remove dynamic * chore: add set state * chore: remove onDateSelected from setState * chore: pageNumber should start from 0 * chore: trigger order by from odata request * chore: remove required * chore: make constructor inline * chore: remove unnecessary setState * chore: rename the method * chore: add onOptionRemoved and make the dropdown wider * fix: revert remove the onOptionRemoved * fix: remove comments * fix: use map instead of List<_Operator> * fix: remove unused scrollController * feat: add intl dependency * chore: add optional and remove unnecessary variables and method * chore: use absent method * fix: change substring to date format * fix: use lastLoginAt * fix: add a statement to check whether the lastLoginAt is null or not * refactor: simplify * chore: remove unused field * chore: remove the unused from method * fix: make method inline * fix: change to named parameter * fix: make field private * fix: undo the change * fix: make method inline * fix: use record * fix: rename variables * fix: use setter * fix: resolve ! for newValue * fix: move callback outside of setState * fix: rename a method * fix: maximum date that can be picked is "tomorrow" * fix: make an early return * fix: move callback outside of setState and make setState inline * fix: trigger onFilterSelected when operator changes * chore: make an early return * fix: use DateFormat * fix: replace variable with null * fix: return picked instead of _selectedDate * chore: make early return, rename variable and make statement inline * fix: remove setState and rename variable * fix: swap places of helper method with main method * fix: revert make method more readable * fix: move the setter's position * fix: remove setState * refactor: cleanup / make more readable --------- Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> Co-authored-by: Julian König Co-authored-by: Timo Notheisen --- .github/CODEOWNERS | 2 +- .../lib/core/widgets/filters/date_filter.dart | 96 ++++++++++++ .../lib/core/widgets/filters/filters.dart | 4 + .../core/widgets/filters/input_filter.dart | 31 ++++ .../core/widgets/filters/multi_select.dart | 51 +++++++ .../core/widgets/filters/number_filter.dart | 66 ++++++++ ...to_filter_operator_dropdown_menu_item.dart | 11 ++ .../admin_ui/lib/core/widgets/widgets.dart | 1 + AdminUi/apps/admin_ui/lib/home/home.dart | 9 +- .../identities_data_table_source.dart | 91 +++++++++++ .../identities_filter.dart | 144 ++++++++++++++++++ .../identities_overview.dart | 116 ++++++++++++++ AdminUi/apps/admin_ui/lib/main.dart | 6 +- AdminUi/apps/admin_ui/pubspec.lock | 32 ++++ AdminUi/apps/admin_ui/pubspec.yaml | 4 + .../admin_api_sdk/lib/admin_api_sdk.dart | 1 + .../identity_overview_filter_builder.dart | 45 +++++- .../lib/src/endpoints/endpoint.dart | 4 +- .../lib/src/endpoints/identity_endpoint.dart | 6 + AdminUi/pubspec.lock | 4 +- 20 files changed, 705 insertions(+), 19 deletions(-) create mode 100644 AdminUi/apps/admin_ui/lib/core/widgets/filters/date_filter.dart create mode 100644 AdminUi/apps/admin_ui/lib/core/widgets/filters/filters.dart create mode 100644 AdminUi/apps/admin_ui/lib/core/widgets/filters/input_filter.dart create mode 100644 AdminUi/apps/admin_ui/lib/core/widgets/filters/multi_select.dart create mode 100644 AdminUi/apps/admin_ui/lib/core/widgets/filters/number_filter.dart create mode 100644 AdminUi/apps/admin_ui/lib/core/widgets/filters/to_filter_operator_dropdown_menu_item.dart create mode 100644 AdminUi/apps/admin_ui/lib/home/identities_overview/identities_data_table_source.dart create mode 100644 AdminUi/apps/admin_ui/lib/home/identities_overview/identities_filter.dart create mode 100644 AdminUi/apps/admin_ui/lib/home/identities_overview/identities_overview.dart diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 542c840ee3..ce7ac59e98 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @tnotheis @jkoenig134 \ No newline at end of file +* @tnotheis @jkoenig134 diff --git a/AdminUi/apps/admin_ui/lib/core/widgets/filters/date_filter.dart b/AdminUi/apps/admin_ui/lib/core/widgets/filters/date_filter.dart new file mode 100644 index 0000000000..3596326170 --- /dev/null +++ b/AdminUi/apps/admin_ui/lib/core/widgets/filters/date_filter.dart @@ -0,0 +1,96 @@ +import 'package:admin_api_sdk/admin_api_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import '/core/constants.dart'; +import 'to_filter_operator_dropdown_menu_item.dart'; + +class DateFilter extends StatefulWidget { + final void Function(FilterOperator operator, DateTime? selectedDate) onFilterSelected; + final String label; + + const DateFilter({required this.onFilterSelected, required this.label, super.key}); + + @override + State createState() => _DateFilterState(); +} + +class _DateFilterState extends State { + FilterOperator _operator = FilterOperator.equal; + DateTime? _selectedDate; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${widget.label}:', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + Gaps.h8, + Row( + children: [ + DropdownButton( + value: _operator, + onChanged: (selectedOperator) { + if (selectedOperator == null) return; + setState(() => _operator = selectedOperator); + widget.onFilterSelected(selectedOperator, _selectedDate); + }, + items: FilterOperator.values.toDropdownMenuItems(), + ), + Gaps.w8, + InkWell( + onTap: _selectNewDate, + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + _selectedDate != null ? DateFormat('yyyy-MM-dd').format(_selectedDate!) : 'Select date', + style: const TextStyle(fontSize: 14), + ), + Gaps.w8, + const Icon(Icons.calendar_today), + if (_selectedDate != null) ...[ + Gaps.w8, + GestureDetector( + onTap: _clearDate, + child: const Icon(Icons.clear, size: 20), + ), + ], + ], + ), + ), + ), + ], + ), + ], + ); + } + + void _clearDate() { + setState(() => _selectedDate = null); + widget.onFilterSelected(_operator, null); + } + + Future _selectNewDate() async { + final picked = await showDatePicker( + context: context, + initialDate: _selectedDate ?? DateTime.now(), + firstDate: DateTime(2000), + lastDate: DateTime.now().add(const Duration(days: 1)), + ); + + if (picked == null) return; + + setState(() => _selectedDate = picked); + widget.onFilterSelected(_operator, picked); + } +} diff --git a/AdminUi/apps/admin_ui/lib/core/widgets/filters/filters.dart b/AdminUi/apps/admin_ui/lib/core/widgets/filters/filters.dart new file mode 100644 index 0000000000..01502796be --- /dev/null +++ b/AdminUi/apps/admin_ui/lib/core/widgets/filters/filters.dart @@ -0,0 +1,4 @@ +export 'date_filter.dart'; +export 'input_filter.dart'; +export 'multi_select.dart'; +export 'number_filter.dart'; diff --git a/AdminUi/apps/admin_ui/lib/core/widgets/filters/input_filter.dart b/AdminUi/apps/admin_ui/lib/core/widgets/filters/input_filter.dart new file mode 100644 index 0000000000..6dbfd30720 --- /dev/null +++ b/AdminUi/apps/admin_ui/lib/core/widgets/filters/input_filter.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +import '/core/core.dart'; + +class InputField extends StatelessWidget { + final void Function(String enteredText) onEnteredText; + final String label; + + const InputField({required this.onEnteredText, required this.label, super.key}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '$label:', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + Gaps.h8, + SizedBox( + width: 180, + child: TextField( + onChanged: onEnteredText, + decoration: const InputDecoration(border: OutlineInputBorder()), + ), + ), + ], + ); + } +} diff --git a/AdminUi/apps/admin_ui/lib/core/widgets/filters/multi_select.dart b/AdminUi/apps/admin_ui/lib/core/widgets/filters/multi_select.dart new file mode 100644 index 0000000000..0f7e7ffee1 --- /dev/null +++ b/AdminUi/apps/admin_ui/lib/core/widgets/filters/multi_select.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:multi_dropdown/multiselect_dropdown.dart'; + +import '/core/core.dart'; + +class MultiSelectFilter extends StatelessWidget { + final String label; + final String searchLabel; + final MultiSelectController controller; + final void Function(List> selectedOptions) onOptionSelected; + + const MultiSelectFilter({ + required this.label, + required this.searchLabel, + required this.controller, + required this.onOptionSelected, + super.key, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '$label:', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + Gaps.h8, + SizedBox( + width: 250, + child: MultiSelectDropDown( + hint: '', + searchLabel: searchLabel, + searchEnabled: true, + controller: controller, + options: controller.options, + fieldBackgroundColor: Theme.of(context).colorScheme.background, + searchBackgroundColor: Theme.of(context).colorScheme.background, + dropdownBackgroundColor: Theme.of(context).colorScheme.background, + selectedOptionBackgroundColor: Theme.of(context).colorScheme.background, + selectedOptionTextColor: Theme.of(context).colorScheme.onBackground, + optionsBackgroundColor: Theme.of(context).colorScheme.background, + optionTextStyle: TextStyle(color: Theme.of(context).colorScheme.onBackground), + onOptionSelected: onOptionSelected, + ), + ), + ], + ); + } +} diff --git a/AdminUi/apps/admin_ui/lib/core/widgets/filters/number_filter.dart b/AdminUi/apps/admin_ui/lib/core/widgets/filters/number_filter.dart new file mode 100644 index 0000000000..ba5ca18ede --- /dev/null +++ b/AdminUi/apps/admin_ui/lib/core/widgets/filters/number_filter.dart @@ -0,0 +1,66 @@ +import 'package:admin_api_sdk/admin_api_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '/core/constants.dart'; +import 'to_filter_operator_dropdown_menu_item.dart'; + +class NumberFilter extends StatefulWidget { + final void Function(FilterOperator operator, String enteredValue) onNumberSelected; + final String label; + + const NumberFilter({ + required this.onNumberSelected, + required this.label, + super.key, + }); + + @override + State createState() => _NumberFilterState(); +} + +class _NumberFilterState extends State { + late FilterOperator _operator = FilterOperator.equal; + late String _value = ''; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${widget.label}:', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + Gaps.h8, + Row( + children: [ + DropdownButton( + value: _operator, + onChanged: (selectedOperator) { + if (selectedOperator == null) return; + setState(() => _operator = selectedOperator); + widget.onNumberSelected(selectedOperator, _value); + }, + items: FilterOperator.values.toDropdownMenuItems(), + ), + Gaps.w16, + SizedBox( + width: 120, + child: TextField( + onChanged: (enteredValue) { + _value = enteredValue; + widget.onNumberSelected(_operator, enteredValue); + }, + decoration: const InputDecoration(border: OutlineInputBorder()), + style: const TextStyle(fontSize: 12), + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + keyboardType: TextInputType.number, + ), + ), + ], + ), + ], + ); + } +} diff --git a/AdminUi/apps/admin_ui/lib/core/widgets/filters/to_filter_operator_dropdown_menu_item.dart b/AdminUi/apps/admin_ui/lib/core/widgets/filters/to_filter_operator_dropdown_menu_item.dart new file mode 100644 index 0000000000..ee08037f49 --- /dev/null +++ b/AdminUi/apps/admin_ui/lib/core/widgets/filters/to_filter_operator_dropdown_menu_item.dart @@ -0,0 +1,11 @@ +import 'package:admin_api_sdk/admin_api_sdk.dart'; +import 'package:flutter/material.dart'; + +extension ToFilterOperatorDropdownMenuItem on List { + List> toDropdownMenuItems() => map( + (operator) => DropdownMenuItem( + value: operator, + child: Text(operator.userFriendlyOperator), + ), + ).toList(); +} diff --git a/AdminUi/apps/admin_ui/lib/core/widgets/widgets.dart b/AdminUi/apps/admin_ui/lib/core/widgets/widgets.dart index bdfa30afdc..30092c1139 100644 --- a/AdminUi/apps/admin_ui/lib/core/widgets/widgets.dart +++ b/AdminUi/apps/admin_ui/lib/core/widgets/widgets.dart @@ -1 +1,2 @@ export 'app_title.dart'; +export 'filters/filters.dart'; diff --git a/AdminUi/apps/admin_ui/lib/home/home.dart b/AdminUi/apps/admin_ui/lib/home/home.dart index d6a6d9556c..75e4f2e2c0 100644 --- a/AdminUi/apps/admin_ui/lib/home/home.dart +++ b/AdminUi/apps/admin_ui/lib/home/home.dart @@ -1,13 +1,6 @@ import 'package:flutter/material.dart'; -class Identities extends StatelessWidget { - const Identities({super.key}); - - @override - Widget build(BuildContext context) { - return const Placeholder(); - } -} +export 'identities_overview/identities_overview.dart'; class Tiers extends StatelessWidget { const Tiers({super.key}); diff --git a/AdminUi/apps/admin_ui/lib/home/identities_overview/identities_data_table_source.dart b/AdminUi/apps/admin_ui/lib/home/identities_overview/identities_data_table_source.dart new file mode 100644 index 0000000000..2910e3285f --- /dev/null +++ b/AdminUi/apps/admin_ui/lib/home/identities_overview/identities_data_table_source.dart @@ -0,0 +1,91 @@ +import 'package:admin_api_sdk/admin_api_sdk.dart'; +import 'package:data_table_2/data_table_2.dart'; +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:intl/intl.dart'; +import 'package:logger/logger.dart'; + +class IdentityDataTableSource extends AsyncDataTableSource { + Pagination? _pagination; + var _sortingSettings = (sortColumnIndex: 0, sortAscending: true); + + IdentityOverviewFilter? _filter; + + set filter(IdentityOverviewFilter? newFilter) { + if (_filter != newFilter) { + _filter = newFilter; + notifyListeners(); + } + } + + void sort({required int sortColumnIndex, required bool sortColumnAscending}) { + _sortingSettings = (sortColumnIndex: sortColumnIndex, sortAscending: sortColumnAscending); + notifyListeners(); + } + + @override + bool get isRowCountApproximate => false; + + @override + int get rowCount => _pagination?.totalRecords ?? 0; + + @override + int get selectedRowCount => 0; + + @override + Future getRows(int startIndex, int count) async { + final pageNumber = startIndex ~/ count; + final orderBy = _getODataOrderBy(); + + try { + final response = await GetIt.I.get().identities.getIdentities( + pageNumber: pageNumber, + pageSize: count, + filter: _filter, + orderBy: orderBy, + ); + _pagination = response.pagination; + + final rows = response.data.indexed + .map( + (identity) => DataRow.byIndex( + index: pageNumber * count + identity.$1, + cells: [ + DataCell(Text(identity.$2.address)), + DataCell(Text(identity.$2.tier.name)), + DataCell(Text(identity.$2.createdWithClient)), + DataCell(Text(identity.$2.numberOfDevices.toString())), + DataCell(Text(DateFormat('yyyy-MM-dd').format(identity.$2.createdAt))), + DataCell(Text(identity.$2.lastLoginAt != null ? DateFormat('yyyy-MM-dd').format(identity.$2.lastLoginAt!) : '')), + DataCell(Text(identity.$2.datawalletVersion?.toString() ?? '')), + DataCell(Text(identity.$2.identityVersion.toString())), + ], + ), + ) + .toList(); + + return AsyncRowsResponse(response.pagination.totalRecords, rows); + } catch (e) { + GetIt.I.get().e('Failed to load data: $e'); + + throw Exception('Failed to load data: $e'); + } + } + + String _getODataOrderBy() { + final columnName = _getFieldNameByIndex(_sortingSettings.sortColumnIndex); + final sortingDirection = _sortingSettings.sortAscending ? 'asc' : 'desc'; + return '$columnName $sortingDirection'; + } + + String _getFieldNameByIndex(int index) => switch (index) { + 0 => 'address', + 2 => 'createdWithClient', + 3 => 'numberOfDevices', + 4 => 'createdAt', + 5 => 'lastLoginAt', + 6 => 'datawalletVersion', + 7 => 'identityVersion', + _ => throw Exception('Invalid column index') + }; +} diff --git a/AdminUi/apps/admin_ui/lib/home/identities_overview/identities_filter.dart b/AdminUi/apps/admin_ui/lib/home/identities_overview/identities_filter.dart new file mode 100644 index 0000000000..71603d641c --- /dev/null +++ b/AdminUi/apps/admin_ui/lib/home/identities_overview/identities_filter.dart @@ -0,0 +1,144 @@ +import 'package:admin_api_sdk/admin_api_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:intl/intl.dart'; +import 'package:multi_dropdown/multiselect_dropdown.dart'; + +import '/core/core.dart'; + +class IdentitiesFilter extends StatefulWidget { + final Future Function({IdentityOverviewFilter? filter}) onFilterChanged; + + const IdentitiesFilter({ + required this.onFilterChanged, + super.key, + }); + + @override + State createState() => _IdentitiesFilterState(); +} + +class _IdentitiesFilterState extends State { + IdentityOverviewFilter _filter = IdentityOverviewFilter(); + + final MultiSelectController _tierController = MultiSelectController(); + final MultiSelectController _clientController = MultiSelectController(); + + @override + void initState() { + super.initState(); + + _loadTiers(); + _loadClients(); + } + + @override + void dispose() { + _tierController.dispose(); + _clientController.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(8), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + InputField( + label: 'Address', + onEnteredText: (String enteredText) { + _filter = _filter.copyWith(address: enteredText.isEmpty ? const Optional.absent() : Optional(enteredText)); + widget.onFilterChanged(filter: _filter); + }, + ), + Gaps.w16, + MultiSelectFilter( + label: 'Tiers', + searchLabel: 'Search Tiers', + controller: _tierController, + onOptionSelected: (List> selectedOptions) { + final selectedTiers = selectedOptions.map((item) => item.value!).toList(); + _filter = _filter.copyWith(tiers: selectedTiers.isEmpty ? const Optional.absent() : Optional(selectedTiers)); + widget.onFilterChanged(filter: _filter); + }, + ), + Gaps.w16, + MultiSelectFilter( + label: 'Clients', + searchLabel: 'Search Clients', + controller: _clientController, + onOptionSelected: (List> selectedOptions) { + final selectedClients = selectedOptions.map((item) => item.value!).toList(); + _filter = _filter.copyWith(clients: selectedClients.isEmpty ? const Optional.absent() : Optional(selectedClients)); + widget.onFilterChanged(filter: _filter); + }, + ), + Gaps.w16, + NumberFilter( + label: 'Number of Devices', + onNumberSelected: (FilterOperator operator, String enteredValue) { + final numberOfDevices = FilterOperatorValue(operator, enteredValue); + _filter = _filter.copyWith(numberOfDevices: numberOfDevices.value.isEmpty ? const Optional.absent() : Optional(numberOfDevices)); + widget.onFilterChanged(filter: _filter); + }, + ), + Gaps.w16, + DateFilter( + label: 'Created At', + onFilterSelected: (FilterOperator operator, DateTime? selectedDate) { + final createdAt = FilterOperatorValue(operator, selectedDate != null ? DateFormat('yyyy-MM-dd').format(selectedDate) : ''); + _filter = _filter.copyWith(createdAt: createdAt.value.isEmpty ? const Optional.absent() : Optional(createdAt)); + widget.onFilterChanged(filter: _filter); + }, + ), + Gaps.w16, + DateFilter( + label: 'Last Login At', + onFilterSelected: (FilterOperator operator, DateTime? selectedDate) { + final lastLoginAt = FilterOperatorValue(operator, selectedDate != null ? DateFormat('yyyy-MM-dd').format(selectedDate) : ''); + _filter = _filter.copyWith(lastLoginAt: lastLoginAt.value.isEmpty ? const Optional.absent() : Optional(lastLoginAt)); + widget.onFilterChanged(filter: _filter); + }, + ), + Gaps.w16, + NumberFilter( + label: 'Datawallet Version', + onNumberSelected: (FilterOperator operator, String enteredValue) { + final datawalletVersion = FilterOperatorValue(operator, enteredValue); + _filter = + _filter.copyWith(datawalletVersion: datawalletVersion.value.isEmpty ? const Optional.absent() : Optional(datawalletVersion)); + widget.onFilterChanged(filter: _filter); + }, + ), + Gaps.w16, + NumberFilter( + label: 'Identity Version', + onNumberSelected: (FilterOperator operator, String enteredValue) { + final identityVersion = FilterOperatorValue(operator, enteredValue); + _filter = _filter.copyWith(identityVersion: identityVersion.value.isEmpty ? const Optional.absent() : Optional(identityVersion)); + widget.onFilterChanged(filter: _filter); + }, + ), + ], + ), + ), + ); + } + + Future _loadTiers() async { + final response = await GetIt.I.get().tiers.getTiers(); + final tierItems = response.data.map((tier) => ValueItem(label: tier.name, value: tier.id)).toList(); + _tierController.setOptions(tierItems); + } + + Future _loadClients() async { + final response = await GetIt.I.get().clients.getClients(); + final clientItems = response.data.map((client) => ValueItem(label: client.displayName, value: client.clientId)).toList(); + _clientController.setOptions(clientItems); + } +} diff --git a/AdminUi/apps/admin_ui/lib/home/identities_overview/identities_overview.dart b/AdminUi/apps/admin_ui/lib/home/identities_overview/identities_overview.dart new file mode 100644 index 0000000000..d7d9bd5eb0 --- /dev/null +++ b/AdminUi/apps/admin_ui/lib/home/identities_overview/identities_overview.dart @@ -0,0 +1,116 @@ +import 'package:admin_api_sdk/admin_api_sdk.dart'; +import 'package:data_table_2/data_table_2.dart'; +import 'package:flutter/material.dart'; + +import '/core/core.dart'; +import 'identities_data_table_source.dart'; +import 'identities_filter.dart'; + +class IdentitiesOverview extends StatefulWidget { + const IdentitiesOverview({super.key}); + + @override + State createState() => _IdentitiesOverviewState(); +} + +class _IdentitiesOverviewState extends State { + late ScrollController _scrollController; + late IdentityDataTableSource _dataSource; + + int _sortColumnIndex = 0; + bool _sortColumnAscending = true; + + int _rowsPerPage = 5; + + @override + void initState() { + super.initState(); + _scrollController = ScrollController(); + _dataSource = IdentityDataTableSource(); + } + + @override + void dispose() { + _scrollController.dispose(); + _dataSource.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Padding( + padding: EdgeInsets.only(left: 32), + child: Text('A list of existing Identities'), + ), + ), + body: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + IdentitiesFilter( + onFilterChanged: ({IdentityOverviewFilter? filter}) async { + _dataSource + ..filter = filter + ..refreshDatasource(); + }, + ), + Expanded( + child: AsyncPaginatedDataTable2( + rowsPerPage: _rowsPerPage, + onRowsPerPageChanged: _setRowsPerPage, + sortColumnIndex: _sortColumnIndex, + sortAscending: _sortColumnAscending, + showFirstLastButtons: true, + columnSpacing: 5, + source: _dataSource, + scrollController: _scrollController, + isVerticalScrollBarVisible: true, + renderEmptyRowsInTheEnd: false, + availableRowsPerPage: const [5, 10, 25, 50, 100], + errorBuilder: (error) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('An error occurred loading the data.'), + Gaps.h16, + FilledButton(onPressed: () => _dataSource.refreshDatasource(), child: const Text('Retry')), + ], + ), + ), + columns: [ + DataColumn2(label: const Text('Address'), size: ColumnSize.L, onSort: _sort), + const DataColumn2(label: Text('Tier'), size: ColumnSize.S), + DataColumn2(label: const Text('Created with Client'), onSort: _sort), + DataColumn2(label: const Text('Number of Devices'), onSort: _sort), + DataColumn2(label: const Text('Created at'), size: ColumnSize.S, onSort: _sort), + DataColumn2(label: const Text('Last Login at'), size: ColumnSize.S, onSort: _sort), + DataColumn2(label: const Text('Datawallet version'), onSort: _sort), + DataColumn2(label: const Text('Identity Version'), onSort: _sort), + ], + ), + ), + ], + ), + ), + ); + } + + void _setRowsPerPage(int? newValue) { + _rowsPerPage = newValue ?? _rowsPerPage; + _dataSource.refreshDatasource(); + } + + void _sort(int columnIndex, bool ascending) { + setState(() { + _sortColumnIndex = columnIndex; + _sortColumnAscending = ascending; + }); + _dataSource + ..sort(sortColumnIndex: _sortColumnIndex, sortColumnAscending: _sortColumnAscending) + ..refreshDatasource(); + } +} diff --git a/AdminUi/apps/admin_ui/lib/main.dart b/AdminUi/apps/admin_ui/lib/main.dart index 1be77a8845..bbc0fa6f11 100644 --- a/AdminUi/apps/admin_ui/lib/main.dart +++ b/AdminUi/apps/admin_ui/lib/main.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; +import 'package:logger/logger.dart'; import 'core/theme/theme.dart'; import 'home/home.dart'; @@ -9,6 +11,8 @@ import 'setup/setup_desktop.dart' if (dart.library.html) 'setup/setup_web.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); + GetIt.I.registerSingleton(Logger()); + await setup(); runApp(const AdminUiApp()); @@ -38,7 +42,7 @@ final _router = GoRouter( GoRoute( parentNavigatorKey: _shellNavigatorKey, path: '/identities', - pageBuilder: (context, state) => const NoTransitionPage(child: Identities()), + pageBuilder: (context, state) => const NoTransitionPage(child: IdentitiesOverview()), ), GoRoute( parentNavigatorKey: _shellNavigatorKey, diff --git a/AdminUi/apps/admin_ui/pubspec.lock b/AdminUi/apps/admin_ui/pubspec.lock index ed3edec0a0..99d299d808 100644 --- a/AdminUi/apps/admin_ui/pubspec.lock +++ b/AdminUi/apps/admin_ui/pubspec.lock @@ -103,6 +103,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + data_table_2: + dependency: "direct main" + description: + name: data_table_2 + sha256: e403de6d9a58dddf27700114b614ea8ea5aa8442d7fbdfbe8b3d11b0512e7a49 + url: "https://pub.dev" + source: hosted + version: "2.5.12" dio: dependency: transitive description: @@ -206,6 +214,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.7" + intl: + dependency: "direct main" + description: + name: intl + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + url: "https://pub.dev" + source: hosted + version: "0.19.0" js: dependency: transitive description: @@ -246,6 +262,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.1" + logger: + dependency: "direct main" + description: + name: logger + sha256: "8c94b8c219e7e50194efc8771cd0e9f10807d8d3e219af473d89b06cc2ee4e04" + url: "https://pub.dev" + source: hosted + version: "2.2.0" logging: dependency: transitive description: @@ -278,6 +302,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.11.0" + multi_dropdown: + dependency: "direct main" + description: + name: multi_dropdown + sha256: b63ff339fcc875d667f8688c8ef62853545b580dd2b6fe78b73339783268afd8 + url: "https://pub.dev" + source: hosted + version: "2.1.4" path: dependency: transitive description: diff --git a/AdminUi/apps/admin_ui/pubspec.yaml b/AdminUi/apps/admin_ui/pubspec.yaml index b288f4d9a4..0c70869c2e 100644 --- a/AdminUi/apps/admin_ui/pubspec.yaml +++ b/AdminUi/apps/admin_ui/pubspec.yaml @@ -9,11 +9,15 @@ environment: dependencies: admin_api_sdk: ^1.0.0 admin_api_types: ^1.0.0 + data_table_2: ^2.5.12 flutter: sdk: flutter flutter_svg: ^2.0.10+1 get_it: ^7.6.7 go_router: ^13.2.0 + intl: ^0.19.0 + logger: ^2.2.0 + multi_dropdown: ^2.1.4 shared_preferences: ^2.2.2 window_size: git: diff --git a/AdminUi/packages/admin_api_sdk/lib/admin_api_sdk.dart b/AdminUi/packages/admin_api_sdk/lib/admin_api_sdk.dart index 27933967b2..e4fbbfe4e3 100644 --- a/AdminUi/packages/admin_api_sdk/lib/admin_api_sdk.dart +++ b/AdminUi/packages/admin_api_sdk/lib/admin_api_sdk.dart @@ -1,2 +1,3 @@ export 'src/admin_api_sdk_base.dart'; +export 'src/builders/builders.dart'; export 'src/types/types.dart'; diff --git a/AdminUi/packages/admin_api_sdk/lib/src/builders/identity_overview_filter_builder.dart b/AdminUi/packages/admin_api_sdk/lib/src/builders/identity_overview_filter_builder.dart index 231801fc6d..6735dfa3cc 100644 --- a/AdminUi/packages/admin_api_sdk/lib/src/builders/identity_overview_filter_builder.dart +++ b/AdminUi/packages/admin_api_sdk/lib/src/builders/identity_overview_filter_builder.dart @@ -69,12 +69,16 @@ class IdentityOverviewFilterBuilder { } enum FilterOperator { - equal, - notEqual, - greaterThan, - greaterThanOrEqual, - lessThan, - lessThanOrEqual, + equal('='), + notEqual('!='), + greaterThan('>'), + greaterThanOrEqual('>='), + lessThan('<'), + lessThanOrEqual('<='); + + final String userFriendlyOperator; + + const FilterOperator(this.userFriendlyOperator); } class IdentityOverviewFilter { @@ -97,6 +101,35 @@ class IdentityOverviewFilter { this.datawalletVersion, this.identityVersion, }); + + IdentityOverviewFilter copyWith({ + Optional? address, + Optional?>? tiers, + Optional?>? clients, + Optional? createdAt, + Optional? lastLoginAt, + Optional? numberOfDevices, + Optional? datawalletVersion, + Optional? identityVersion, + }) { + return IdentityOverviewFilter( + address: (address != null) ? address.value : this.address, + tiers: (tiers != null) ? tiers.value : this.tiers, + clients: (clients != null) ? clients.value : this.clients, + createdAt: (createdAt != null) ? createdAt.value : this.createdAt, + lastLoginAt: (lastLoginAt != null) ? lastLoginAt.value : this.lastLoginAt, + numberOfDevices: (numberOfDevices != null) ? numberOfDevices.value : this.numberOfDevices, + datawalletVersion: (datawalletVersion != null) ? datawalletVersion.value : this.datawalletVersion, + identityVersion: (identityVersion != null) ? identityVersion.value : this.identityVersion, + ); + } +} + +class Optional { + final T? value; + + const Optional(this.value); + const Optional.absent() : value = null; } class FilterOperatorValue { diff --git a/AdminUi/packages/admin_api_sdk/lib/src/endpoints/endpoint.dart b/AdminUi/packages/admin_api_sdk/lib/src/endpoints/endpoint.dart index d8fb17a9ad..b589109b6a 100644 --- a/AdminUi/packages/admin_api_sdk/lib/src/endpoints/endpoint.dart +++ b/AdminUi/packages/admin_api_sdk/lib/src/endpoints/endpoint.dart @@ -98,6 +98,7 @@ abstract class Endpoint { Future> getOData( String path, { required T Function(dynamic) transformer, + required String orderBy, required int pageNumber, required int pageSize, Map? query, @@ -106,8 +107,9 @@ abstract class Endpoint { path, queryParameters: { r'$top': '$pageSize', - r'$skip': '${pageNumber * pageSize}', + r'$skip': '$pageNumber', r'$count': 'true', + r'$orderBy': orderBy, ...query ?? {}, }, options: Options(headers: {'Accept': 'application/json'}), diff --git a/AdminUi/packages/admin_api_sdk/lib/src/endpoints/identity_endpoint.dart b/AdminUi/packages/admin_api_sdk/lib/src/endpoints/identity_endpoint.dart index e20d6a86b5..ad926f9b1f 100644 --- a/AdminUi/packages/admin_api_sdk/lib/src/endpoints/identity_endpoint.dart +++ b/AdminUi/packages/admin_api_sdk/lib/src/endpoints/identity_endpoint.dart @@ -28,6 +28,7 @@ class IdentitiesEndpoint extends Endpoint { ); Future>> getIdentities({ + String orderBy = 'address asc', IdentityOverviewFilter? filter, int pageNumber = 0, int pageSize = 10, @@ -38,9 +39,14 @@ class IdentitiesEndpoint extends Endpoint { queryParameters[r'$filter'] = IdentityOverviewFilterBuilder(filter).build(); } + if (queryParameters[r'$filter'] == '') { + queryParameters.remove(r'$filter'); + } + return getOData( '/odata/Identities', query: queryParameters, + orderBy: orderBy, transformer: (e) => (e as List).map(IdentityOverview.fromJson).toList(), pageNumber: pageNumber, pageSize: pageSize, diff --git a/AdminUi/pubspec.lock b/AdminUi/pubspec.lock index 65616aa097..355c8e0cbe 100644 --- a/AdminUi/pubspec.lock +++ b/AdminUi/pubspec.lock @@ -149,10 +149,10 @@ packages: dependency: transitive description: name: meta - sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" + sha256: d584fa6707a52763a52446f02cc621b077888fb63b93bbcb1143a7be5a0c0c04 url: "https://pub.dev" source: hosted - version: "1.12.0" + version: "1.11.0" mustache_template: dependency: transitive description: