feat: relation field improvements (#5070)

* feat: keyboard navigation for relation cell editor

* feat: open related rows

* feat: separated selected and unselected rows

* chore: apply suggestions from code review

Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>

* chore: fix launch review issues

* chore: add documentation

---------

Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>
This commit is contained in:
Richard Shiue 2024-04-10 20:39:20 +08:00 committed by GitHub
parent 3bbba2eeb4
commit 066a511dc5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 664 additions and 120 deletions

View File

@ -1,7 +1,9 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:bloc/bloc.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
@ -24,11 +26,22 @@ class RelationRowSearchBloc
(event, emit) {
event.when(
didUpdateRowList: (List<RelatedRowDataPB> rowList) {
allRows.clear();
allRows.addAll(rowList);
emit(state.copyWith(filteredRows: allRows));
allRows
..clear()
..addAll(rowList);
emit(
state.copyWith(
filteredRows: allRows,
focusedRowId: state.focusedRowId ?? allRows.firstOrNull?.rowId,
),
);
},
updateFilter: (String filter) => _updateFilter(filter, emit),
updateFocusedOption: (String rowId) {
emit(state.copyWith(focusedRowId: rowId));
},
focusPreviousOption: () => _focusOption(true, emit),
focusNextOption: () => _focusOption(false, emit),
);
},
);
@ -45,12 +58,50 @@ class RelationRowSearchBloc
void _updateFilter(String filter, Emitter<RelationRowSearchState> emit) {
final rows = [...allRows];
if (filter.isNotEmpty) {
rows.retainWhere(
(row) => row.name.toLowerCase().contains(filter.toLowerCase()),
(row) =>
row.name.toLowerCase().contains(filter.toLowerCase()) ||
(row.name.isEmpty &&
LocaleKeys.grid_row_titlePlaceholder
.tr()
.toLowerCase()
.contains(filter.toLowerCase())),
);
}
emit(state.copyWith(filter: filter, filteredRows: rows));
final focusedRowId = rows.isEmpty
? null
: rows.any((row) => row.rowId == state.focusedRowId)
? state.focusedRowId
: rows.first.rowId;
emit(
state.copyWith(
filteredRows: rows,
focusedRowId: focusedRowId,
),
);
}
void _focusOption(bool previous, Emitter<RelationRowSearchState> emit) {
if (state.filteredRows.isEmpty) {
return;
}
final rowIds = state.filteredRows.map((e) => e.rowId).toList();
final currentIndex = state.focusedRowId == null
? -1
: rowIds.indexWhere((id) => id == state.focusedRowId);
// If the current index is -1, it means that the focused row is not in the list of row ids.
// In this case, we set the new index to the last index if previous is true, otherwise to 0.
final newIndex = currentIndex == -1
? (previous ? rowIds.length - 1 : 0)
: (currentIndex + (previous ? -1 : 1)) % rowIds.length;
emit(state.copyWith(focusedRowId: rowIds[newIndex]));
}
}
@ -61,17 +112,23 @@ class RelationRowSearchEvent with _$RelationRowSearchEvent {
) = _DidUpdateRowList;
const factory RelationRowSearchEvent.updateFilter(String filter) =
_UpdateFilter;
const factory RelationRowSearchEvent.updateFocusedOption(
String rowId,
) = _UpdateFocusedOption;
const factory RelationRowSearchEvent.focusPreviousOption() =
_FocusPreviousOption;
const factory RelationRowSearchEvent.focusNextOption() = _FocusNextOption;
}
@freezed
class RelationRowSearchState with _$RelationRowSearchState {
const factory RelationRowSearchState({
required String filter,
required List<RelatedRowDataPB> filteredRows,
required String? focusedRowId,
}) = _RelationRowSearchState;
factory RelationRowSearchState.initial() => const RelationRowSearchState(
filter: "",
filteredRows: [],
focusedRowId: null,
);
}

View File

@ -0,0 +1,124 @@
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:bloc/bloc.dart';
import 'package:collection/collection.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import '../database_controller.dart';
import 'row_controller.dart';
part 'related_row_detail_bloc.freezed.dart';
class RelatedRowDetailPageBloc
extends Bloc<RelatedRowDetailPageEvent, RelatedRowDetailPageState> {
RelatedRowDetailPageBloc({
required String databaseId,
required String initialRowId,
}) : super(const RelatedRowDetailPageState.loading()) {
_dispatch();
_init(databaseId, initialRowId);
}
@override
Future<void> close() {
state.whenOrNull(
ready: (databaseController, rowController) {
rowController.dispose();
databaseController.dispose();
},
);
return super.close();
}
void _dispatch() {
on<RelatedRowDetailPageEvent>((event, emit) async {
event.when(
didInitialize: (databaseController, rowController) {
state.maybeWhen(
ready: (_, oldRowController) {
oldRowController.dispose();
emit(
RelatedRowDetailPageState.ready(
databaseController: databaseController,
rowController: rowController,
),
);
},
orElse: () {
emit(
RelatedRowDetailPageState.ready(
databaseController: databaseController,
rowController: rowController,
),
);
},
);
},
);
});
}
/// initialize bloc through the `database_id` and `row_id`. The process is as
/// follows:
/// 1. use the `database_id` to get the database meta, which contains the
/// `inline_view_id`
/// 2. use the `inline_view_id` to instantiate a `DatabaseController`.
/// 3. use the `row_id` with the DatabaseController` to create `RowController`
void _init(String databaseId, String initialRowId) async {
final databaseMeta = await DatabaseEventGetDatabases()
.send()
.fold<DatabaseMetaPB?>(
(s) => s.items
.firstWhereOrNull((metaPB) => metaPB.databaseId == databaseId),
(f) => null,
);
if (databaseMeta == null) {
return;
}
final inlineView =
await ViewBackendService.getView(databaseMeta.inlineViewId)
.fold((viewPB) => viewPB, (f) => null);
if (inlineView == null) {
return;
}
final databaseController = DatabaseController(view: inlineView);
await databaseController.open().fold(
(s) => databaseController.setIsLoading(false),
(f) => null,
);
final rowInfo = databaseController.rowCache.getRow(initialRowId);
if (rowInfo == null) {
return;
}
final rowController = RowController(
rowMeta: rowInfo.rowMeta,
viewId: inlineView.id,
rowCache: databaseController.rowCache,
);
add(
RelatedRowDetailPageEvent.didInitialize(
databaseController,
rowController,
),
);
}
}
@freezed
class RelatedRowDetailPageEvent with _$RelatedRowDetailPageEvent {
const factory RelatedRowDetailPageEvent.didInitialize(
DatabaseController databaseController,
RowController rowController,
) = _DidInitialize;
}
@freezed
class RelatedRowDetailPageState with _$RelatedRowDetailPageState {
const factory RelatedRowDetailPageState.loading() = _LoadingState;
const factory RelatedRowDetailPageState.ready({
required DatabaseController databaseController,
required RowController rowController,
}) = _ReadyState;
}

View File

@ -29,9 +29,7 @@ class DesktopGridRelationCellSkin extends IEditableRelationCellSkin {
popupBuilder: (context) {
return BlocProvider.value(
value: bloc,
child: RelationCellEditor(
selectedRowIds: state.rows.map((row) => row.rowId).toList(),
),
child: const RelationCellEditor(),
);
},
child: Container(

View File

@ -29,9 +29,7 @@ class DesktopRowDetailRelationCellSkin extends IEditableRelationCellSkin {
popupBuilder: (context) {
return BlocProvider.value(
value: bloc,
child: RelationCellEditor(
selectedRowIds: state.rows.map((row) => row.rowId).toList(),
),
child: const RelationCellEditor(),
);
},
child: Container(

View File

@ -1,11 +1,18 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/application/field/type_option/relation_type_option_cubit.dart';
import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/common/type_option_separator.dart';
import 'package:appflowy/plugins/database/widgets/row/relation_row_detail.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../application/cell/bloc/relation_cell_bloc.dart';
@ -14,132 +21,451 @@ import '../../application/cell/bloc/relation_row_search_bloc.dart';
class RelationCellEditor extends StatelessWidget {
const RelationCellEditor({
super.key,
required this.selectedRowIds,
});
final List<String> selectedRowIds;
@override
Widget build(BuildContext context) {
return BlocBuilder<RelationCellBloc, RelationCellState>(
builder: (context, cellState) {
if (cellState.relatedDatabaseMeta == null) {
return const _RelationCellEditorDatabaseList();
return cellState.relatedDatabaseMeta == null
? const _RelationCellEditorDatabasePicker()
: _RelationCellEditorContent(
relatedDatabaseMeta: cellState.relatedDatabaseMeta!,
selectedRowIds: cellState.rows.map((e) => e.rowId).toList(),
);
},
);
}
}
class _RelationCellEditorContent extends StatefulWidget {
const _RelationCellEditorContent({
required this.relatedDatabaseMeta,
required this.selectedRowIds,
});
final DatabaseMeta relatedDatabaseMeta;
final List<String> selectedRowIds;
@override
State<_RelationCellEditorContent> createState() =>
_RelationCellEditorContentState();
}
class _RelationCellEditorContentState
extends State<_RelationCellEditorContent> {
final textEditingController = TextEditingController();
late final FocusNode focusNode;
late final bloc = RelationRowSearchBloc(
databaseId: widget.relatedDatabaseMeta.databaseId,
);
@override
void initState() {
super.initState();
focusNode = FocusNode(
onKeyEvent: (node, event) {
switch (event.logicalKey) {
case LogicalKeyboardKey.arrowUp when event is! KeyUpEvent:
if (textEditingController.value.composing.isCollapsed) {
bloc.add(const RelationRowSearchEvent.focusPreviousOption());
return KeyEventResult.handled;
}
break;
case LogicalKeyboardKey.arrowDown when event is! KeyUpEvent:
if (textEditingController.value.composing.isCollapsed) {
bloc.add(const RelationRowSearchEvent.focusNextOption());
return KeyEventResult.handled;
}
break;
case LogicalKeyboardKey.escape when event is! KeyUpEvent:
if (!textEditingController.value.composing.isCollapsed) {
final end = textEditingController.value.composing.end;
final text = textEditingController.text;
textEditingController.value = TextEditingValue(
text: text,
selection: TextSelection.collapsed(offset: end),
);
return KeyEventResult.handled;
}
break;
}
return KeyEventResult.ignored;
},
);
}
return BlocProvider<RelationRowSearchBloc>(
create: (context) => RelationRowSearchBloc(
databaseId: cellState.relatedDatabaseMeta!.databaseId,
),
child: BlocBuilder<RelationRowSearchBloc, RelationRowSearchState>(
builder: (context, state) {
final children = state.filteredRows
.map(
(row) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 6.0),
child: FlowyButton(
text: FlowyText.medium(
row.name.trim().isEmpty
? LocaleKeys.grid_title_placeholder.tr()
: row.name,
color: row.name.trim().isEmpty
? Theme.of(context).hintColor
: null,
overflow: TextOverflow.ellipsis,
),
rightIcon: cellState.rows
.map((e) => e.rowId)
.contains(row.rowId)
? const FlowySvg(
FlowySvgs.check_s,
)
: null,
onTap: () => context
.read<RelationCellBloc>()
.add(RelationCellEvent.selectRow(row.rowId)),
),
),
)
.toList();
@override
void dispose() {
textEditingController.dispose();
focusNode.dispose();
bloc.close();
super.dispose();
}
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 6.0) +
GridSize.typeOptionContentInsets,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
FlowyText.regular(
LocaleKeys.grid_relation_inRelatedDatabase.tr(),
fontSize: 11,
color: Theme.of(context).hintColor,
),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 2,
),
child: FlowyText.regular(
cellState.relatedDatabaseMeta!.databaseName,
fontSize: 11,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 6.0),
child: FlowyTextField(
hintText: LocaleKeys
.grid_relation_rowSearchTextFieldPlaceholder
.tr(),
hintStyle: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(color: Theme.of(context).hintColor),
onChanged: (text) => context
.read<RelationRowSearchBloc>()
.add(RelationRowSearchEvent.updateFilter(text)),
),
),
const VSpace(6.0),
const TypeOptionSeparator(spacing: 0.0),
if (state.filteredRows.isEmpty)
Padding(
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: bloc,
child: BlocBuilder<RelationRowSearchBloc, RelationRowSearchState>(
buildWhen: (previous, current) =>
!listEquals(previous.filteredRows, current.filteredRows),
builder: (context, state) {
final selected = <RelatedRowDataPB>[];
final unselected = <RelatedRowDataPB>[];
for (final row in state.filteredRows) {
if (widget.selectedRowIds.contains(row.rowId)) {
selected.add(row);
} else {
unselected.add(row);
}
}
return TextFieldTapRegion(
child: CustomScrollView(
shrinkWrap: true,
slivers: [
_CellEditorTitle(
databaseName: widget.relatedDatabaseMeta.databaseName,
),
_SearchField(
focusNode: focusNode,
textEditingController: textEditingController,
),
const SliverToBoxAdapter(
child: TypeOptionSeparator(spacing: 0.0),
),
if (state.filteredRows.isEmpty)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(6.0) +
GridSize.typeOptionContentInsets,
child: FlowyText.regular(
LocaleKeys.grid_relation_emptySearchResult.tr(),
color: Theme.of(context).hintColor,
),
)
else
Flexible(
child: ListView.separated(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(vertical: 6.0),
separatorBuilder: (context, index) =>
VSpace(GridSize.typeOptionSeparatorHeight),
itemCount: children.length,
itemBuilder: (context, index) => children[index],
),
),
if (selected.isNotEmpty) ...[
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6.0) +
GridSize.typeOptionContentInsets,
child: FlowyText.regular(
LocaleKeys.grid_relation_linkedRowListLabel.plural(
selected.length,
namedArgs: {'count': '${selected.length}'},
),
fontSize: 11,
overflow: TextOverflow.ellipsis,
color: Theme.of(context).hintColor,
),
),
),
_RowList(
databaseId: widget.relatedDatabaseMeta.databaseId,
rows: selected,
isSelected: true,
),
const SliverToBoxAdapter(
child: VSpace(4.0),
),
],
);
},
),
);
},
if (unselected.isNotEmpty) ...[
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6.0) +
GridSize.typeOptionContentInsets,
child: FlowyText.regular(
LocaleKeys.grid_relation_unlinkedRowListLabel.tr(),
fontSize: 11,
overflow: TextOverflow.ellipsis,
color: Theme.of(context).hintColor,
),
),
),
_RowList(
databaseId: widget.relatedDatabaseMeta.databaseId,
rows: unselected,
isSelected: false,
),
const SliverToBoxAdapter(
child: VSpace(4.0),
),
],
],
),
);
},
),
);
}
}
class _RelationCellEditorDatabaseList extends StatelessWidget {
const _RelationCellEditorDatabaseList();
class _CellEditorTitle extends StatelessWidget {
const _CellEditorTitle({
required this.databaseName,
});
final String databaseName;
@override
Widget build(BuildContext context) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6.0) +
GridSize.typeOptionContentInsets,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
FlowyText.regular(
LocaleKeys.grid_relation_inRelatedDatabase.tr(),
fontSize: 11,
color: Theme.of(context).hintColor,
),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 2,
),
child: FlowyText.regular(
databaseName,
fontSize: 11,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
);
}
}
class _SearchField extends StatelessWidget {
const _SearchField({
required this.focusNode,
required this.textEditingController,
});
final FocusNode focusNode;
final TextEditingController textEditingController;
@override
Widget build(BuildContext context) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(left: 6.0, bottom: 6.0, right: 6.0),
child: FlowyTextField(
focusNode: focusNode,
controller: textEditingController,
hintText: LocaleKeys.grid_relation_rowSearchTextFieldPlaceholder.tr(),
hintStyle: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(color: Theme.of(context).hintColor),
onChanged: (text) {
if (textEditingController.value.composing.isCollapsed) {
context
.read<RelationRowSearchBloc>()
.add(RelationRowSearchEvent.updateFilter(text));
}
},
onSubmitted: (_) {
final focusedRowId =
context.read<RelationRowSearchBloc>().state.focusedRowId;
if (focusedRowId != null) {
final row = context
.read<RelationCellBloc>()
.state
.rows
.firstWhereOrNull((e) => e.rowId == focusedRowId);
if (row != null) {
FlowyOverlay.show(
context: context,
builder: (BuildContext overlayContext) {
return RelatedRowDetailPage(
databaseId: context
.read<RelationCellBloc>()
.state
.relatedDatabaseMeta!
.databaseId,
rowId: row.rowId,
);
},
);
PopoverContainer.of(context).close();
} else {
context
.read<RelationCellBloc>()
.add(RelationCellEvent.selectRow(focusedRowId));
}
}
focusNode.requestFocus();
},
),
),
);
}
}
class _RowList extends StatelessWidget {
const _RowList({
required this.databaseId,
required this.rows,
required this.isSelected,
});
final String databaseId;
final List<RelatedRowDataPB> rows;
final bool isSelected;
@override
Widget build(BuildContext context) {
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => _RowListItem(
row: rows[index],
databaseId: databaseId,
isSelected: isSelected,
),
childCount: rows.length,
),
);
}
}
class _RowListItem extends StatelessWidget {
const _RowListItem({
required this.row,
required this.isSelected,
required this.databaseId,
});
final RelatedRowDataPB row;
final String databaseId;
final bool isSelected;
@override
Widget build(BuildContext context) {
final isHovered =
context.watch<RelationRowSearchBloc>().state.focusedRowId == row.rowId;
return Container(
height: 28,
margin: const EdgeInsets.symmetric(horizontal: 6.0, vertical: 2.0),
decoration: BoxDecoration(
color: isHovered ? AFThemeExtension.of(context).lightGreyHover : null,
borderRadius: const BorderRadius.all(Radius.circular(6)),
),
child: GestureDetector(
onTap: () {
if (isSelected) {
FlowyOverlay.show(
context: context,
builder: (BuildContext overlayContext) {
return RelatedRowDetailPage(
databaseId: databaseId,
rowId: row.rowId,
);
},
);
PopoverContainer.of(context).close();
} else {
context
.read<RelationCellBloc>()
.add(RelationCellEvent.selectRow(row.rowId));
}
},
child: MouseRegion(
cursor: SystemMouseCursors.click,
onHover: (_) => context
.read<RelationRowSearchBloc>()
.add(RelationRowSearchEvent.updateFocusedOption(row.rowId)),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6.0, vertical: 4.0),
child: Row(
children: [
Expanded(
child: FlowyText.medium(
row.name.trim().isEmpty
? LocaleKeys.grid_title_placeholder.tr()
: row.name,
color: row.name.trim().isEmpty
? Theme.of(context).hintColor
: null,
overflow: TextOverflow.ellipsis,
),
),
if (isSelected && isHovered)
_UnselectRowButton(
onPressed: () => context
.read<RelationCellBloc>()
.add(RelationCellEvent.selectRow(row.rowId)),
),
],
),
),
),
),
);
}
}
class _UnselectRowButton extends StatefulWidget {
const _UnselectRowButton({
required this.onPressed,
});
final VoidCallback onPressed;
@override
State<_UnselectRowButton> createState() => _UnselectRowButtonState();
}
class _UnselectRowButtonState extends State<_UnselectRowButton> {
final _materialStatesController = MaterialStatesController();
@override
void dispose() {
_materialStatesController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return TextButton(
onPressed: widget.onPressed,
onHover: (_) => setState(() {}),
onFocusChange: (_) => setState(() {}),
style: ButtonStyle(
fixedSize: const MaterialStatePropertyAll(Size.square(32)),
minimumSize: const MaterialStatePropertyAll(Size.square(32)),
maximumSize: const MaterialStatePropertyAll(Size.square(32)),
overlayColor: MaterialStateProperty.resolveWith((state) {
if (state.contains(MaterialState.focused)) {
return AFThemeExtension.of(context).greyHover;
}
return Colors.transparent;
}),
shape: const MaterialStatePropertyAll(
RoundedRectangleBorder(borderRadius: Corners.s6Border),
),
),
statesController: _materialStatesController,
child: Container(
color: _materialStatesController.value
.contains(MaterialState.hovered) ||
_materialStatesController.value.contains(MaterialState.focused)
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onBackground,
width: 12,
height: 1,
),
);
}
}
class _RelationCellEditorDatabasePicker extends StatelessWidget {
const _RelationCellEditorDatabasePicker();
@override
Widget build(BuildContext context) {

View File

@ -479,7 +479,7 @@ class SelectOptionTagCell extends StatelessWidget {
alignment: AlignmentDirectional.centerStart,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 5.0,
horizontal: 6.0,
vertical: 4.0,
),
child: SelectOptionTag(

View File

@ -0,0 +1,39 @@
import 'package:appflowy/plugins/database/application/row/related_row_detail_bloc.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'row_detail.dart';
class RelatedRowDetailPage extends StatelessWidget {
const RelatedRowDetailPage({
super.key,
required this.databaseId,
required this.rowId,
});
final String databaseId;
final String rowId;
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => RelatedRowDetailPageBloc(
databaseId: databaseId,
initialRowId: rowId,
),
child: BlocBuilder<RelatedRowDetailPageBloc, RelatedRowDetailPageState>(
builder: (context, state) {
return state.when(
loading: () => const SizedBox.shrink(),
ready: (databaseController, rowController) {
return RowDetailPage(
databaseController: databaseController,
rowController: rowController,
);
},
);
},
),
);
}
}

View File

@ -774,7 +774,9 @@
"inRelatedDatabase": "In",
"rowSearchTextFieldPlaceholder": "Search",
"noDatabaseSelected": "No database selected, please select one first from the list below:",
"emptySearchResult": "No records found"
"emptySearchResult": "No records found",
"linkedRowListLabel": "{count} linked rows",
"unlinkedRowListLabel": "Link another row"
},
"menuName": "Grid",
"referencedGridPrefix": "View of",