mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2025-12-27 15:13:46 +00:00
feat: shrinkWrap grid in document (#6925)
* feat: shrinkWrap grid in document * fix: clean up code and minor fixes * test: add test w/ load more option * fix: reinstate pageview & clean unused code * fix: clean database tab bar view
This commit is contained in:
parent
0bf706f438
commit
1d46923c47
@ -1,12 +1,15 @@
|
||||
import 'package:appflowy/plugins/database/board/presentation/board_page.dart';
|
||||
import 'package:appflowy/plugins/database/calendar/presentation/calendar_page.dart';
|
||||
import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart';
|
||||
import 'package:appflowy/plugins/database/grid/presentation/widgets/footer/grid_footer.dart';
|
||||
import 'package:appflowy/plugins/database/grid/presentation/widgets/row/row.dart';
|
||||
import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/text.dart';
|
||||
import 'package:appflowy/plugins/inline_actions/widgets/inline_actions_handler.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flowy_infra/uuid.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
@ -174,9 +177,110 @@ void main() {
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('insert a referenced grid with many rows (load more option)',
|
||||
(tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
|
||||
await insertLinkedDatabase(tester, ViewLayoutPB.Grid);
|
||||
|
||||
// validate the referenced grid is inserted
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(AppFlowyEditor),
|
||||
matching: find.byType(GridPage),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
// https://github.com/AppFlowy-IO/AppFlowy/issues/3533
|
||||
// test: the selection of editor should be clear when editing the grid
|
||||
await tester.editor.updateSelection(
|
||||
Selection.collapsed(
|
||||
Position(path: [1]),
|
||||
),
|
||||
);
|
||||
final gridTextCell = find.byType(EditableTextCell).first;
|
||||
await tester.tapButton(gridTextCell);
|
||||
|
||||
expect(tester.editor.getCurrentEditorState().selection, isNull);
|
||||
|
||||
final editorScrollable = find
|
||||
.descendant(
|
||||
of: find.byType(AppFlowyEditor),
|
||||
matching: find.byWidgetPredicate(
|
||||
(w) => w is Scrollable && w.axis == Axis.vertical,
|
||||
),
|
||||
)
|
||||
.first;
|
||||
|
||||
// Add 100 Rows to the linked database
|
||||
final addRowFinder = find.byType(GridAddRowButton);
|
||||
for (var i = 0; i < 100; i++) {
|
||||
await tester.scrollUntilVisible(
|
||||
addRowFinder,
|
||||
100,
|
||||
scrollable: editorScrollable,
|
||||
);
|
||||
await tester.tapButton(addRowFinder);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
// Since all rows visible are those we added, we should see all of them
|
||||
expect(find.byType(GridRow), findsNWidgets(103));
|
||||
|
||||
// Navigate to getting started
|
||||
await tester.openPage(gettingStarted);
|
||||
|
||||
// Navigate back to the document
|
||||
await tester.openPage('insert_a_reference_${ViewLayoutPB.Grid.name}');
|
||||
|
||||
// We see only 25 Grid Rows
|
||||
expect(find.byType(GridRow), findsNWidgets(25));
|
||||
|
||||
// We see Add row and load more button
|
||||
expect(find.byType(GridAddRowButton), findsOneWidget);
|
||||
expect(find.byType(GridRowLoadMoreButton), findsOneWidget);
|
||||
|
||||
// Load more rows, expect 50 visible
|
||||
await _loadMoreRows(tester, editorScrollable, 50);
|
||||
|
||||
// Load more rows, expect 75 visible
|
||||
await _loadMoreRows(tester, editorScrollable, 75);
|
||||
|
||||
// Load more rows, expect 100 visible
|
||||
await _loadMoreRows(tester, editorScrollable, 100);
|
||||
|
||||
// Load more rows, expect 103 visible
|
||||
await _loadMoreRows(tester, editorScrollable, 103);
|
||||
|
||||
// We no longer see load more option
|
||||
expect(find.byType(GridRowLoadMoreButton), findsNothing);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadMoreRows(
|
||||
WidgetTester tester,
|
||||
Finder scrollable, [
|
||||
int? expectedRows,
|
||||
]) async {
|
||||
await tester.scrollUntilVisible(
|
||||
find.byType(GridRowLoadMoreButton),
|
||||
100,
|
||||
scrollable: scrollable,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byType(GridRowLoadMoreButton));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
if (expectedRows != null) {
|
||||
expect(find.byType(GridRow), findsNWidgets(expectedRows));
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a referenced database of [layout] into the document
|
||||
Future<void> insertLinkedDatabase(
|
||||
WidgetTester tester,
|
||||
|
||||
@ -54,6 +54,7 @@ class BoardPageTabBarBuilderImpl extends DatabaseTabBarItemBuilder {
|
||||
key: _makeValueKey(controller),
|
||||
view: view,
|
||||
databaseController: controller,
|
||||
shrinkWrap: shrinkWrap,
|
||||
)
|
||||
: MobileBoardPage(
|
||||
key: _makeValueKey(controller),
|
||||
@ -98,6 +99,7 @@ class DesktopBoardPage extends StatefulWidget {
|
||||
required this.view,
|
||||
required this.databaseController,
|
||||
this.onEditStateChanged,
|
||||
this.shrinkWrap = false,
|
||||
});
|
||||
|
||||
final ViewPB view;
|
||||
@ -107,6 +109,9 @@ class DesktopBoardPage extends StatefulWidget {
|
||||
/// Called when edit state changed
|
||||
final VoidCallback? onEditStateChanged;
|
||||
|
||||
/// If true, the board will shrink wrap its content
|
||||
final bool shrinkWrap;
|
||||
|
||||
@override
|
||||
State<DesktopBoardPage> createState() => _DesktopBoardPageState();
|
||||
}
|
||||
@ -187,24 +192,17 @@ class _DesktopBoardPageState extends State<DesktopBoardPage> {
|
||||
Widget build(BuildContext context) {
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider.value(
|
||||
value: _boardBloc,
|
||||
),
|
||||
BlocProvider.value(
|
||||
value: _boardActionsCubit,
|
||||
),
|
||||
BlocProvider<BoardBloc>.value(value: _boardBloc),
|
||||
BlocProvider.value(value: _boardActionsCubit),
|
||||
],
|
||||
child: BlocBuilder<BoardBloc, BoardState>(
|
||||
builder: (context, state) => state.maybeMap(
|
||||
loading: (_) => const Center(
|
||||
child: CircularProgressIndicator.adaptive(),
|
||||
),
|
||||
error: (err) => Center(
|
||||
child: AppFlowyErrorPage(
|
||||
error: err.error,
|
||||
),
|
||||
),
|
||||
error: (err) => Center(child: AppFlowyErrorPage(error: err.error)),
|
||||
orElse: () => _BoardContent(
|
||||
shrinkWrap: widget.shrinkWrap,
|
||||
onEditStateChanged: widget.onEditStateChanged,
|
||||
focusScope: _focusScope,
|
||||
boardController: _boardController,
|
||||
@ -243,11 +241,13 @@ class _BoardContent extends StatefulWidget {
|
||||
required this.boardController,
|
||||
required this.focusScope,
|
||||
this.onEditStateChanged,
|
||||
this.shrinkWrap = false,
|
||||
});
|
||||
|
||||
final AppFlowyBoardController boardController;
|
||||
final BoardFocusScope focusScope;
|
||||
final VoidCallback? onEditStateChanged;
|
||||
final bool shrinkWrap;
|
||||
|
||||
@override
|
||||
State<_BoardContent> createState() => _BoardContentState();
|
||||
@ -358,12 +358,8 @@ class _BoardContentState extends State<_BoardContent> {
|
||||
),
|
||||
footerBuilder: (_, groupData) => MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider.value(
|
||||
value: context.read<BoardBloc>(),
|
||||
),
|
||||
BlocProvider.value(
|
||||
value: context.read<BoardActionsCubit>(),
|
||||
),
|
||||
BlocProvider.value(value: context.read<BoardBloc>()),
|
||||
BlocProvider.value(value: context.read<BoardActionsCubit>()),
|
||||
],
|
||||
child: BoardColumnFooter(
|
||||
columnData: groupData,
|
||||
|
||||
@ -20,13 +20,20 @@ import '../../application/database_controller.dart';
|
||||
part 'grid_bloc.freezed.dart';
|
||||
|
||||
class GridBloc extends Bloc<GridEvent, GridState> {
|
||||
GridBloc({required ViewPB view, required this.databaseController})
|
||||
: super(GridState.initial(view.id)) {
|
||||
GridBloc({
|
||||
required ViewPB view,
|
||||
required this.databaseController,
|
||||
this.shrinkWrapped = false,
|
||||
}) : super(GridState.initial(view.id)) {
|
||||
_dispatch();
|
||||
}
|
||||
|
||||
final DatabaseController databaseController;
|
||||
|
||||
/// When true will emit the count of visible rows to show
|
||||
///
|
||||
final bool shrinkWrapped;
|
||||
|
||||
String get viewId => databaseController.viewId;
|
||||
|
||||
UserProfilePB? _userProfile;
|
||||
@ -64,12 +71,22 @@ class GridBloc extends Bloc<GridEvent, GridState> {
|
||||
);
|
||||
},
|
||||
createRow: (openRowDetail) async {
|
||||
final result = await RowBackendService.createRow(viewId: viewId);
|
||||
final lastVisibleRowId =
|
||||
shrinkWrapped ? state.lastVisibleRow?.rowId : null;
|
||||
|
||||
final result = await RowBackendService.createRow(
|
||||
viewId: viewId,
|
||||
position: lastVisibleRowId != null
|
||||
? OrderObjectPositionTypePB.After
|
||||
: null,
|
||||
targetRowId: lastVisibleRowId,
|
||||
);
|
||||
result.fold(
|
||||
(createdRow) => emit(
|
||||
state.copyWith(
|
||||
createdRow: createdRow,
|
||||
openRowDetail: openRowDetail ?? false,
|
||||
visibleRows: state.visibleRows + 1,
|
||||
),
|
||||
),
|
||||
(err) => Log.error(err),
|
||||
@ -93,11 +110,7 @@ class GridBloc extends Bloc<GridEvent, GridState> {
|
||||
databaseController.moveRow(fromRowId: fromRow, toRowId: toRow);
|
||||
},
|
||||
didReceiveFieldUpdate: (fields) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
fields: fields,
|
||||
),
|
||||
);
|
||||
emit(state.copyWith(fields: fields));
|
||||
},
|
||||
didLoadRows: (newRowInfos, reason) {
|
||||
emit(
|
||||
@ -109,17 +122,13 @@ class GridBloc extends Bloc<GridEvent, GridState> {
|
||||
);
|
||||
},
|
||||
didReceveFilters: (filters) {
|
||||
emit(
|
||||
state.copyWith(filters: filters),
|
||||
);
|
||||
emit(state.copyWith(filters: filters));
|
||||
},
|
||||
didReceveSorts: (sorts) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
reorderable: sorts.isEmpty,
|
||||
sorts: sorts,
|
||||
),
|
||||
);
|
||||
emit(state.copyWith(reorderable: sorts.isEmpty, sorts: sorts));
|
||||
},
|
||||
loadMoreRows: () {
|
||||
emit(state.copyWith(visibleRows: state.visibleRows + 25));
|
||||
},
|
||||
);
|
||||
},
|
||||
@ -204,11 +213,11 @@ class GridEvent with _$GridEvent {
|
||||
const factory GridEvent.didReceiveFieldUpdate(
|
||||
List<FieldInfo> fields,
|
||||
) = _DidReceiveFieldUpdate;
|
||||
|
||||
const factory GridEvent.didReceveFilters(List<DatabaseFilter> filters) =
|
||||
_DidReceiveFilters;
|
||||
const factory GridEvent.didReceveSorts(List<DatabaseSort> sorts) =
|
||||
_DidReceiveSorts;
|
||||
const factory GridEvent.loadMoreRows() = _LoadMoreRows;
|
||||
}
|
||||
|
||||
@freezed
|
||||
@ -225,6 +234,7 @@ class GridState with _$GridState {
|
||||
required List<DatabaseSort> sorts,
|
||||
required List<DatabaseFilter> filters,
|
||||
required bool openRowDetail,
|
||||
@Default(0) int visibleRows,
|
||||
}) = _GridState;
|
||||
|
||||
factory GridState.initial(String viewId) => GridState(
|
||||
@ -239,5 +249,19 @@ class GridState with _$GridState {
|
||||
filters: [],
|
||||
sorts: [],
|
||||
openRowDetail: false,
|
||||
visibleRows: 25,
|
||||
);
|
||||
}
|
||||
|
||||
extension _LastVisibleRow on GridState {
|
||||
/// Returns the last visible [RowInfo] in the list of [rowInfos].
|
||||
/// Only returns if the visibleRows is less than the rowCount, otherwise returns null.
|
||||
///
|
||||
RowInfo? get lastVisibleRow {
|
||||
if (visibleRows < rowCount) {
|
||||
return rowInfos[visibleRows - 1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:appflowy/plugins/database/grid/presentation/widgets/toolbar/grid_setting_bar.dart';
|
||||
import 'package:appflowy/plugins/database/tab_bar/desktop/setting_menu.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
@ -8,8 +9,6 @@ import 'package:appflowy/plugins/database/application/row/row_service.dart';
|
||||
import 'package:appflowy/plugins/database/application/tab_bar_bloc.dart';
|
||||
import 'package:appflowy/plugins/database/domain/sort_service.dart';
|
||||
import 'package:appflowy/plugins/database/grid/presentation/widgets/calculations/calculations_row.dart';
|
||||
import 'package:appflowy/plugins/database/grid/presentation/widgets/toolbar/grid_setting_bar.dart';
|
||||
import 'package:appflowy/plugins/database/tab_bar/desktop/setting_menu.dart';
|
||||
import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart';
|
||||
import 'package:appflowy/shared/flowy_error_page.dart';
|
||||
import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart';
|
||||
@ -67,6 +66,7 @@ class DesktopGridTabBarBuilderImpl extends DatabaseTabBarItemBuilder {
|
||||
view: view,
|
||||
databaseController: controller,
|
||||
initialRowId: initialRowId,
|
||||
shrinkWrap: shrinkWrap,
|
||||
);
|
||||
}
|
||||
|
||||
@ -110,12 +110,14 @@ class GridPage extends StatefulWidget {
|
||||
required this.databaseController,
|
||||
this.onDeleted,
|
||||
this.initialRowId,
|
||||
this.shrinkWrap = false,
|
||||
});
|
||||
|
||||
final ViewPB view;
|
||||
final DatabaseController databaseController;
|
||||
final VoidCallback? onDeleted;
|
||||
final String? initialRowId;
|
||||
final bool shrinkWrap;
|
||||
|
||||
@override
|
||||
State<GridPage> createState() => _GridPageState();
|
||||
@ -124,13 +126,22 @@ class GridPage extends StatefulWidget {
|
||||
class _GridPageState extends State<GridPage> {
|
||||
bool _didOpenInitialRow = false;
|
||||
|
||||
late final GridBloc gridBloc = GridBloc(
|
||||
view: widget.view,
|
||||
databaseController: widget.databaseController,
|
||||
shrinkWrapped: widget.shrinkWrap,
|
||||
)..add(const GridEvent.initial());
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
gridBloc.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<GridBloc>(
|
||||
create: (context) => GridBloc(
|
||||
view: widget.view,
|
||||
databaseController: widget.databaseController,
|
||||
)..add(const GridEvent.initial()),
|
||||
create: (_) => gridBloc,
|
||||
child: BlocListener<ActionNavigationBloc, ActionNavigationState>(
|
||||
listener: (context, state) {
|
||||
final action = state.action;
|
||||
@ -147,6 +158,7 @@ class _GridPageState extends State<GridPage> {
|
||||
child: BlocConsumer<GridBloc, GridState>(
|
||||
listener: listener,
|
||||
builder: (context, state) => state.loadingState.map(
|
||||
idle: (_) => const SizedBox.shrink(),
|
||||
loading: (_) => const Center(
|
||||
child: CircularProgressIndicator.adaptive(),
|
||||
),
|
||||
@ -155,25 +167,18 @@ class _GridPageState extends State<GridPage> {
|
||||
child: GridPageContent(
|
||||
key: ValueKey(widget.view.id),
|
||||
view: widget.view,
|
||||
shrinkWrap: widget.shrinkWrap,
|
||||
),
|
||||
),
|
||||
(err) => Center(
|
||||
child: AppFlowyErrorPage(
|
||||
error: err,
|
||||
),
|
||||
),
|
||||
(err) => Center(child: AppFlowyErrorPage(error: err)),
|
||||
),
|
||||
idle: (_) => const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openRow(
|
||||
BuildContext context,
|
||||
String rowId,
|
||||
) {
|
||||
void _openRow(BuildContext context, String rowId) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final gridBloc = context.read<GridBloc>();
|
||||
final rowCache = gridBloc.rowCache;
|
||||
@ -249,9 +254,11 @@ class GridPageContent extends StatefulWidget {
|
||||
const GridPageContent({
|
||||
super.key,
|
||||
required this.view,
|
||||
this.shrinkWrap = false,
|
||||
});
|
||||
|
||||
final ViewPB view;
|
||||
final bool shrinkWrap;
|
||||
|
||||
@override
|
||||
State<GridPageContent> createState() => _GridPageContentState();
|
||||
@ -279,13 +286,13 @@ class _GridPageContentState extends State<GridPageContent> {
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_GridHeader(
|
||||
headerScrollController: headerScrollController,
|
||||
),
|
||||
_GridHeader(headerScrollController: headerScrollController),
|
||||
_GridRows(
|
||||
viewId: widget.view.id,
|
||||
scrollController: _scrollController,
|
||||
shrinkWrap: widget.shrinkWrap,
|
||||
),
|
||||
],
|
||||
);
|
||||
@ -300,12 +307,10 @@ class _GridHeader extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<GridBloc, GridState>(
|
||||
builder: (context, state) {
|
||||
return GridHeaderSliverAdaptor(
|
||||
viewId: state.viewId,
|
||||
anchorScrollController: headerScrollController,
|
||||
);
|
||||
},
|
||||
builder: (_, state) => GridHeaderSliverAdaptor(
|
||||
viewId: state.viewId,
|
||||
anchorScrollController: headerScrollController,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -314,11 +319,17 @@ class _GridRows extends StatefulWidget {
|
||||
const _GridRows({
|
||||
required this.viewId,
|
||||
required this.scrollController,
|
||||
this.shrinkWrap = false,
|
||||
});
|
||||
|
||||
final String viewId;
|
||||
final GridScrollController scrollController;
|
||||
|
||||
/// When [shrinkWrap] is active, the Grid will show items according to
|
||||
/// GridState.visibleRows and will not have a vertical scroll area.
|
||||
///
|
||||
final bool shrinkWrap;
|
||||
|
||||
@override
|
||||
State<_GridRows> createState() => _GridRowsState();
|
||||
}
|
||||
@ -330,8 +341,10 @@ class _GridRowsState extends State<_GridRows> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_evaluateFloatingCalculations();
|
||||
widget.scrollController.verticalController.addListener(_onScrollChanged);
|
||||
if (!widget.shrinkWrap) {
|
||||
_evaluateFloatingCalculations();
|
||||
widget.scrollController.verticalController.addListener(_onScrollChanged);
|
||||
}
|
||||
}
|
||||
|
||||
void _onScrollChanged() {
|
||||
@ -345,13 +358,16 @@ class _GridRowsState extends State<_GridRows> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.scrollController.verticalController.removeListener(_onScrollChanged);
|
||||
if (!widget.shrinkWrap) {
|
||||
widget.scrollController.verticalController
|
||||
.removeListener(_onScrollChanged);
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _evaluateFloatingCalculations() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
if (mounted && !widget.shrinkWrap) {
|
||||
setState(() {
|
||||
final verticalController = widget.scrollController.verticalController;
|
||||
// maxScrollExtent is 0.0 if scrolling is not possible
|
||||
@ -367,121 +383,59 @@ class _GridRowsState extends State<_GridRows> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<GridBloc, GridState>(
|
||||
buildWhen: (previous, current) => previous.fields != current.fields,
|
||||
builder: (context, state) {
|
||||
return Flexible(
|
||||
child: LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints layoutConstraits) {
|
||||
return _WrapScrollView(
|
||||
scrollController: widget.scrollController,
|
||||
contentWidth: GridLayout.headerWidth(
|
||||
context
|
||||
.read<DatabasePluginWidgetBuilderSize>()
|
||||
.horizontalPadding,
|
||||
state.fields,
|
||||
),
|
||||
child: BlocConsumer<GridBloc, GridState>(
|
||||
listenWhen: (previous, current) =>
|
||||
previous.rowCount != current.rowCount,
|
||||
listener: (context, state) => _evaluateFloatingCalculations(),
|
||||
builder: (context, state) {
|
||||
return ScrollConfiguration(
|
||||
behavior: ScrollConfiguration.of(context).copyWith(
|
||||
scrollbars: false,
|
||||
),
|
||||
child: _renderList(context, state, layoutConstraits),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _renderList(
|
||||
BuildContext context,
|
||||
GridState state,
|
||||
BoxConstraints layoutConstraints,
|
||||
) {
|
||||
// 1. GridRowBottomBar
|
||||
// 2. GridCalculationsRow
|
||||
final itemCount =
|
||||
state.rowInfos.length + (showFloatingCalculations ? 1 : 2);
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ReorderableListView.builder(
|
||||
/// This is a workaround related to
|
||||
/// https://github.com/flutter/flutter/issues/25652
|
||||
cacheExtent: max(layoutConstraints.maxHeight, 500),
|
||||
scrollController: widget.scrollController.verticalController,
|
||||
physics: const ClampingScrollPhysics(),
|
||||
buildDefaultDragHandles: false,
|
||||
proxyDecorator: (child, _, __) => Provider.value(
|
||||
value: context.read<DatabasePluginWidgetBuilderSize>(),
|
||||
child: Material(
|
||||
color: Colors.white.withOpacity(.1),
|
||||
child: Opacity(opacity: .5, child: child),
|
||||
),
|
||||
Widget child;
|
||||
if (widget.shrinkWrap) {
|
||||
child = SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
controller: widget.scrollController.horizontalController,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: GridLayout.headerWidth(
|
||||
context.read<DatabasePluginWidgetBuilderSize>().horizontalPadding,
|
||||
context.read<GridBloc>().state.fields,
|
||||
),
|
||||
onReorder: (fromIndex, newIndex) {
|
||||
void moveRow() {
|
||||
final toIndex = newIndex > fromIndex ? newIndex - 1 : newIndex;
|
||||
if (fromIndex != toIndex) {
|
||||
context
|
||||
.read<GridBloc>()
|
||||
.add(GridEvent.moveRow(fromIndex, toIndex));
|
||||
}
|
||||
}
|
||||
|
||||
if (state.sorts.isNotEmpty) {
|
||||
showCancelAndDeleteDialog(
|
||||
context: context,
|
||||
title: LocaleKeys.grid_sort_sortsActive.tr(
|
||||
namedArgs: {
|
||||
'intention':
|
||||
LocaleKeys.grid_row_reorderRowDescription.tr(),
|
||||
},
|
||||
),
|
||||
description: LocaleKeys.grid_sort_removeSorting.tr(),
|
||||
confirmLabel: LocaleKeys.button_remove.tr(),
|
||||
closeOnAction: true,
|
||||
onDelete: () {
|
||||
SortBackendService(viewId: widget.viewId).deleteAllSorts();
|
||||
moveRow();
|
||||
},
|
||||
);
|
||||
} else {
|
||||
moveRow();
|
||||
}
|
||||
},
|
||||
itemCount: itemCount,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == state.rowInfos.length) {
|
||||
return const GridRowBottomBar(key: Key('grid_footer'));
|
||||
}
|
||||
|
||||
if (index == state.rowInfos.length + 1 &&
|
||||
!showFloatingCalculations) {
|
||||
return GridCalculationsRow(
|
||||
key: const Key('grid_calculations'),
|
||||
viewId: widget.viewId,
|
||||
);
|
||||
}
|
||||
|
||||
return _renderRow(
|
||||
context,
|
||||
state.rowInfos[index].rowId,
|
||||
index: index,
|
||||
);
|
||||
},
|
||||
),
|
||||
child: _renderList(context),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
child = _WrapScrollView(
|
||||
scrollController: widget.scrollController,
|
||||
contentWidth: GridLayout.headerWidth(
|
||||
context.read<DatabasePluginWidgetBuilderSize>().horizontalPadding,
|
||||
context.read<GridBloc>().state.fields,
|
||||
),
|
||||
child: BlocListener<GridBloc, GridState>(
|
||||
listenWhen: (previous, current) =>
|
||||
previous.rowCount != current.rowCount,
|
||||
listener: (context, state) => _evaluateFloatingCalculations(),
|
||||
child: ScrollConfiguration(
|
||||
behavior:
|
||||
ScrollConfiguration.of(context).copyWith(scrollbars: false),
|
||||
child: _renderList(context),
|
||||
),
|
||||
),
|
||||
if (showFloatingCalculations) ...[
|
||||
);
|
||||
}
|
||||
|
||||
if (widget.shrinkWrap) {
|
||||
return child;
|
||||
}
|
||||
|
||||
return Flexible(child: child);
|
||||
}
|
||||
|
||||
Widget _renderList(BuildContext context) {
|
||||
final state = context.read<GridBloc>().state;
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
widget.shrinkWrap
|
||||
? _reorderableListView(state)
|
||||
: Expanded(child: _reorderableListView(state)),
|
||||
if (showFloatingCalculations && !widget.shrinkWrap) ...[
|
||||
_PositionedCalculationsRow(
|
||||
viewId: widget.viewId,
|
||||
isAtBottom: isAtBottom,
|
||||
@ -491,6 +445,77 @@ class _GridRowsState extends State<_GridRows> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _reorderableListView(GridState state) {
|
||||
final List<Widget> footer = [
|
||||
const GridRowBottomBar(),
|
||||
if (widget.shrinkWrap && state.visibleRows < state.rowInfos.length)
|
||||
const GridRowLoadMoreButton(),
|
||||
if (!showFloatingCalculations) GridCalculationsRow(viewId: widget.viewId),
|
||||
];
|
||||
|
||||
// If we are using shrinkWrap, we need to show at most
|
||||
// state.visibleRows + 1 items. The visibleRows can be larger
|
||||
// than the actual rowInfos length.
|
||||
final itemCount = widget.shrinkWrap
|
||||
? (state.visibleRows + 1).clamp(0, state.rowInfos.length + 1)
|
||||
: state.rowInfos.length + 1;
|
||||
|
||||
return ReorderableListView.builder(
|
||||
cacheExtent: 500,
|
||||
scrollController: widget.scrollController.verticalController,
|
||||
physics: const ClampingScrollPhysics(),
|
||||
buildDefaultDragHandles: false,
|
||||
shrinkWrap: widget.shrinkWrap,
|
||||
proxyDecorator: (child, _, __) => Provider.value(
|
||||
value: context.read<DatabasePluginWidgetBuilderSize>(),
|
||||
child: Material(
|
||||
color: Colors.white.withOpacity(.1),
|
||||
child: Opacity(opacity: .5, child: child),
|
||||
),
|
||||
),
|
||||
onReorder: (fromIndex, newIndex) {
|
||||
final toIndex = newIndex > fromIndex ? newIndex - 1 : newIndex;
|
||||
|
||||
if (state.sorts.isNotEmpty) {
|
||||
showCancelAndDeleteDialog(
|
||||
context: context,
|
||||
title: LocaleKeys.grid_sort_sortsActive.tr(
|
||||
namedArgs: {
|
||||
'intention': LocaleKeys.grid_row_reorderRowDescription.tr(),
|
||||
},
|
||||
),
|
||||
description: LocaleKeys.grid_sort_removeSorting.tr(),
|
||||
confirmLabel: LocaleKeys.button_remove.tr(),
|
||||
closeOnAction: true,
|
||||
onDelete: () {
|
||||
SortBackendService(viewId: widget.viewId).deleteAllSorts();
|
||||
moveRow(fromIndex, toIndex);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
moveRow(fromIndex, toIndex);
|
||||
}
|
||||
},
|
||||
itemCount: itemCount,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == itemCount - 1) {
|
||||
return Column(
|
||||
key: const Key('grid_footer'),
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: footer,
|
||||
);
|
||||
}
|
||||
|
||||
return _renderRow(
|
||||
context,
|
||||
state.rowInfos[index].rowId,
|
||||
index: index,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _renderRow(
|
||||
BuildContext context,
|
||||
RowId rowId, {
|
||||
@ -509,6 +534,7 @@ class _GridRowsState extends State<_GridRows> {
|
||||
|
||||
final child = GridRow(
|
||||
key: ValueKey("grid_row_$rowId"),
|
||||
shrinkWrap: widget.shrinkWrap,
|
||||
fieldController: databaseController.fieldController,
|
||||
rowId: rowId,
|
||||
viewId: viewId,
|
||||
@ -523,20 +549,22 @@ class _GridRowsState extends State<_GridRows> {
|
||||
context: rowDetailContext,
|
||||
builder: (_) {
|
||||
final rowMeta = rowCache.getRow(rowId)?.rowMeta;
|
||||
return rowMeta == null
|
||||
? const SizedBox.shrink()
|
||||
: BlocProvider.value(
|
||||
value: context.read<ViewBloc>(),
|
||||
child: RowDetailPage(
|
||||
rowController: RowController(
|
||||
viewId: viewId,
|
||||
rowMeta: rowMeta,
|
||||
rowCache: rowCache,
|
||||
),
|
||||
databaseController: databaseController,
|
||||
userProfile: context.read<GridBloc>().userProfile,
|
||||
),
|
||||
);
|
||||
if (rowMeta == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return BlocProvider.value(
|
||||
value: context.read<ViewBloc>(),
|
||||
child: RowDetailPage(
|
||||
rowController: RowController(
|
||||
viewId: viewId,
|
||||
rowMeta: rowMeta,
|
||||
rowCache: rowCache,
|
||||
),
|
||||
databaseController: databaseController,
|
||||
userProfile: context.read<GridBloc>().userProfile,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
@ -547,6 +575,12 @@ class _GridRowsState extends State<_GridRows> {
|
||||
|
||||
return child;
|
||||
}
|
||||
|
||||
void moveRow(int from, int to) {
|
||||
if (from != to) {
|
||||
context.read<GridBloc>().add(GridEvent.moveRow(from, to));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _WrapScrollView extends StatelessWidget {
|
||||
|
||||
@ -57,3 +57,44 @@ class GridRowBottomBar extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class GridRowLoadMoreButton extends StatelessWidget {
|
||||
const GridRowLoadMoreButton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final padding =
|
||||
context.read<DatabasePluginWidgetBuilderSize>().horizontalPadding;
|
||||
final color = Theme.of(context).brightness == Brightness.light
|
||||
? const Color(0xFF171717).withOpacity(0.4)
|
||||
: const Color(0xFFFFFFFF).withOpacity(0.4);
|
||||
|
||||
return Container(
|
||||
padding: GridSize.footerContentInsets.copyWith(left: 0) +
|
||||
EdgeInsets.only(left: padding),
|
||||
height: GridSize.footerHeight,
|
||||
child: FlowyButton(
|
||||
radius: BorderRadius.zero,
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(color: AFThemeExtension.of(context).borderColor),
|
||||
),
|
||||
),
|
||||
text: FlowyText(
|
||||
lineHeight: 1.0,
|
||||
LocaleKeys.grid_row_loadMore.tr(),
|
||||
color: color,
|
||||
),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12),
|
||||
hoverColor: AFThemeExtension.of(context).lightGreyHover,
|
||||
onTap: () => context.read<GridBloc>().add(
|
||||
const GridEvent.loadMoreRows(),
|
||||
),
|
||||
leftIcon: FlowySvg(
|
||||
FlowySvgs.load_more_s,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -34,6 +34,7 @@ class GridRow extends StatelessWidget {
|
||||
required this.cellBuilder,
|
||||
required this.openDetailPage,
|
||||
required this.index,
|
||||
this.shrinkWrap = false,
|
||||
});
|
||||
|
||||
final FieldController fieldController;
|
||||
@ -43,9 +44,20 @@ class GridRow extends StatelessWidget {
|
||||
final EditableCellBuilder cellBuilder;
|
||||
final void Function(BuildContext context) openDetailPage;
|
||||
final int index;
|
||||
final bool shrinkWrap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget rowContent = RowContent(
|
||||
fieldController: fieldController,
|
||||
cellBuilder: cellBuilder,
|
||||
onExpand: () => openDetailPage(context),
|
||||
);
|
||||
|
||||
if (!shrinkWrap) {
|
||||
rowContent = Expanded(child: rowContent);
|
||||
}
|
||||
|
||||
return BlocProvider(
|
||||
create: (_) => RowBloc(
|
||||
fieldController: fieldController,
|
||||
@ -56,17 +68,8 @@ class GridRow extends StatelessWidget {
|
||||
child: _RowEnterRegion(
|
||||
child: Row(
|
||||
children: [
|
||||
_RowLeading(
|
||||
viewId: viewId,
|
||||
index: index,
|
||||
),
|
||||
Expanded(
|
||||
child: RowContent(
|
||||
fieldController: fieldController,
|
||||
cellBuilder: cellBuilder,
|
||||
onExpand: () => openDetailPage(context),
|
||||
),
|
||||
),
|
||||
_RowLeading(viewId: viewId, index: index),
|
||||
rowContent,
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@ -55,7 +55,7 @@ abstract class DatabaseTabBarItemBuilder {
|
||||
void dispose() {}
|
||||
}
|
||||
|
||||
class DatabaseTabBarView extends StatefulWidget {
|
||||
class DatabaseTabBarView extends StatelessWidget {
|
||||
const DatabaseTabBarView({
|
||||
super.key,
|
||||
required this.view,
|
||||
@ -70,108 +70,84 @@ class DatabaseTabBarView extends StatefulWidget {
|
||||
///
|
||||
final String? initialRowId;
|
||||
|
||||
@override
|
||||
State<DatabaseTabBarView> createState() => _DatabaseTabBarViewState();
|
||||
}
|
||||
|
||||
class _DatabaseTabBarViewState extends State<DatabaseTabBarView> {
|
||||
final PageController _pageController = PageController();
|
||||
late String? _initialRowId = widget.initialRowId;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider<DatabaseTabBarBloc>(
|
||||
create: (_) => DatabaseTabBarBloc(view: widget.view)
|
||||
create: (_) => DatabaseTabBarBloc(view: view)
|
||||
..add(const DatabaseTabBarEvent.initial()),
|
||||
),
|
||||
BlocProvider<ViewBloc>(
|
||||
create: (_) =>
|
||||
ViewBloc(view: widget.view)..add(const ViewEvent.initial()),
|
||||
create: (_) => ViewBloc(view: view)..add(const ViewEvent.initial()),
|
||||
),
|
||||
],
|
||||
child: MultiBlocListener(
|
||||
listeners: [
|
||||
BlocListener<DatabaseTabBarBloc, DatabaseTabBarState>(
|
||||
listenWhen: (p, c) => p.selectedIndex != c.selectedIndex,
|
||||
listener: (_, state) {
|
||||
_initialRowId = null;
|
||||
_pageController.jumpToPage(state.selectedIndex);
|
||||
},
|
||||
),
|
||||
],
|
||||
child: Column(
|
||||
children: [
|
||||
if (UniversalPlatform.isMobile) const VSpace(12),
|
||||
BlocBuilder<DatabaseTabBarBloc, DatabaseTabBarState>(
|
||||
builder: (context, state) {
|
||||
return ValueListenableBuilder<bool>(
|
||||
valueListenable: state
|
||||
.tabBarControllerByViewId[state.parentView.id]!
|
||||
.controller
|
||||
.isLoading,
|
||||
builder: (_, value, ___) {
|
||||
if (value) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
child: BlocBuilder<DatabaseTabBarBloc, DatabaseTabBarState>(
|
||||
builder: (_, state) {
|
||||
final layout = state.tabBars[state.selectedIndex].layout;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (UniversalPlatform.isMobile) const VSpace(12),
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: state
|
||||
.tabBarControllerByViewId[state.parentView.id]!
|
||||
.controller
|
||||
.isLoading,
|
||||
builder: (_, value, ___) {
|
||||
if (value) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return UniversalPlatform.isDesktop
|
||||
? const TabBarHeader()
|
||||
: const MobileTabBarHeader();
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
BlocBuilder<DatabaseTabBarBloc, DatabaseTabBarState>(
|
||||
builder: (context, state) =>
|
||||
pageSettingBarExtensionFromState(state),
|
||||
),
|
||||
BlocBuilder<DatabaseTabBarBloc, DatabaseTabBarState>(
|
||||
builder: (context, state) {
|
||||
final content = PageView(
|
||||
controller: _pageController,
|
||||
pageSnapping: false,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
children: pageContentFromState(state),
|
||||
);
|
||||
|
||||
if (widget.shrinkWrap) {
|
||||
final layout = state.tabBars[state.selectedIndex].layout;
|
||||
return SizedBox(height: layout.pluginHeight, child: content);
|
||||
}
|
||||
|
||||
return Expanded(child: content);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
return UniversalPlatform.isDesktop
|
||||
? const TabBarHeader()
|
||||
: const MobileTabBarHeader();
|
||||
},
|
||||
),
|
||||
pageSettingBarExtensionFromState(context, state),
|
||||
wrapContent(
|
||||
layout: layout,
|
||||
child: pageContentFromState(context, state),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> pageContentFromState(DatabaseTabBarState state) {
|
||||
return state.tabBars.map((tabBar) {
|
||||
final controller =
|
||||
state.tabBarControllerByViewId[tabBar.viewId]!.controller;
|
||||
Widget wrapContent({required ViewLayoutPB layout, required Widget child}) {
|
||||
if (shrinkWrap) {
|
||||
if (layout.shrinkWrappable) {
|
||||
return child;
|
||||
}
|
||||
|
||||
return tabBar.builder.content(
|
||||
context,
|
||||
tabBar.view,
|
||||
controller,
|
||||
widget.shrinkWrap,
|
||||
_initialRowId,
|
||||
return SizedBox(
|
||||
height: layout.pluginHeight,
|
||||
child: child,
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
return Expanded(child: child);
|
||||
}
|
||||
|
||||
Widget pageSettingBarExtensionFromState(DatabaseTabBarState state) {
|
||||
Widget pageContentFromState(BuildContext context, DatabaseTabBarState state) {
|
||||
final tab = state.tabBars[state.selectedIndex];
|
||||
final controller = state.tabBarControllerByViewId[tab.viewId]!.controller;
|
||||
|
||||
return tab.builder.content(
|
||||
context,
|
||||
tab.view,
|
||||
controller,
|
||||
shrinkWrap,
|
||||
initialRowId,
|
||||
);
|
||||
}
|
||||
|
||||
Widget pageSettingBarExtensionFromState(
|
||||
BuildContext context,
|
||||
DatabaseTabBarState state,
|
||||
) {
|
||||
if (state.tabBars.length < state.selectedIndex) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
@ -75,15 +75,16 @@ class _BuiltInPageWidgetState extends State<BuiltInPageWidget> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _build(BuildContext context, ViewPB view) {
|
||||
Widget _build(BuildContext context, ViewPB viewPB) {
|
||||
return MouseRegion(
|
||||
onEnter: (_) => widget.editorState.service.scrollService?.disable(),
|
||||
onExit: (_) => widget.editorState.service.scrollService?.enable(),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildMenu(context, view),
|
||||
_buildPage(context, view),
|
||||
_buildMenu(context, viewPB),
|
||||
Flexible(child: _buildPage(context, viewPB)),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@ -71,12 +71,7 @@ class _DatabaseBlockComponentWidgetState
|
||||
Widget child = BuiltInPageWidget(
|
||||
node: widget.node,
|
||||
editorState: editorState,
|
||||
builder: (viewPB) {
|
||||
return DatabaseViewWidget(
|
||||
key: ValueKey(viewPB.id),
|
||||
view: viewPB,
|
||||
);
|
||||
},
|
||||
builder: (view) => DatabaseViewWidget(key: ValueKey(view.id), view: view),
|
||||
);
|
||||
|
||||
child = Padding(
|
||||
|
||||
@ -325,13 +325,15 @@ extension ViewLayoutExtension on ViewLayoutPB {
|
||||
_ => LocaleKeys.menuAppHeader_defaultNewPageName.tr(),
|
||||
};
|
||||
|
||||
bool get shrinkWrappable => switch (this) {
|
||||
ViewLayoutPB.Grid => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
double get pluginHeight => switch (this) {
|
||||
ViewLayoutPB.Grid ||
|
||||
ViewLayoutPB.Board ||
|
||||
ViewLayoutPB.Document ||
|
||||
ViewLayoutPB.Chat =>
|
||||
450,
|
||||
ViewLayoutPB.Document || ViewLayoutPB.Board || ViewLayoutPB.Chat => 450,
|
||||
ViewLayoutPB.Calendar => 650,
|
||||
ViewLayoutPB.Grid => double.infinity,
|
||||
_ => throw UnimplementedError(),
|
||||
};
|
||||
}
|
||||
|
||||
1
frontend/resources/flowy_icons/16x/load_more.svg
Normal file
1
frontend/resources/flowy_icons/16x/load_more.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg viewBox="-0.5 -0.5 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" id="Arrow-Down--Streamline-Solar-Ar.svg" height="16" width="16"><desc>Arrow Down Streamline Icon: https://streamlinehq.com</desc><path d="m7.5 2.5 0 10m0 0 3.75 -3.75m-3.75 3.75 -3.75 -3.75" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1"></path></svg>
|
||||
|
After Width: | Height: | Size: 363 B |
@ -1513,6 +1513,7 @@
|
||||
"copyProperty": "Copied property to clipboard",
|
||||
"count": "Count",
|
||||
"newRow": "New row",
|
||||
"loadMore": "Load more",
|
||||
"action": "Action",
|
||||
"add": "Click add to below",
|
||||
"drag": "Drag to move",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user