feat: disable moving page into the database (#3107)

This commit is contained in:
Lucas.Xu
2023-08-03 14:06:02 +07:00
committed by GitHub
parent 135d8a811e
commit fb9bc359b8
9 changed files with 182 additions and 28 deletions

View File

@ -2,7 +2,9 @@ import 'package:appflowy/plugins/database_view/board/presentation/board_page.dar
import 'package:appflowy/plugins/database_view/calendar/presentation/calendar_page.dart'; import 'package:appflowy/plugins/database_view/calendar/presentation/calendar_page.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/grid_page.dart'; import 'package:appflowy/plugins/database_view/grid/presentation/grid_page.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/draggable_view_item.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/draggable_view_item.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_add_button.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_more_action_button.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -138,5 +140,70 @@ void main() {
.id, .id,
); );
}); });
testWidgets('unable to move a document into a database', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
const document = 'document';
await tester.createNewPageWithName(
name: document,
openAfterCreated: false,
);
tester.expectToSeePageName(document, layout: ViewLayoutPB.Document);
const grid = 'grid';
await tester.createNewPageWithName(
name: grid,
layout: ViewLayoutPB.Grid,
openAfterCreated: false,
);
tester.expectToSeePageName(grid, layout: ViewLayoutPB.Grid);
// move the document to the grid page
await tester.movePageToOtherPage(
name: document,
parentName: grid,
layout: ViewLayoutPB.Document,
parentLayout: ViewLayoutPB.Grid,
);
// it should not be moved
final childViews = tester
.widget<SingleInnerViewItem>(tester.findPageName(gettingStated))
.view
.childViews;
expect(
childViews[0].name,
document,
);
expect(
childViews[1].name,
grid,
);
});
testWidgets('unable to create a new database inside the existing one',
(tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
const grid = 'grid';
await tester.createNewPageWithName(
name: grid,
layout: ViewLayoutPB.Grid,
openAfterCreated: true,
);
tester.expectToSeePageName(grid, layout: ViewLayoutPB.Grid);
await tester.hoverOnPageName(
grid,
layout: ViewLayoutPB.Grid,
onHover: () async {
expect(find.byType(ViewAddButton), findsNothing);
expect(find.byType(ViewMoreActionButton), findsOneWidget);
},
);
});
}); });
} }

View File

@ -351,7 +351,7 @@ extension CommonOperations on WidgetTester {
await hoverOnPageName( await hoverOnPageName(
name, name,
layout: layout, layout: layout,
useLast: false, useLast: true,
onHover: () async { onHover: () async {
await tapFavoritePageButton(); await tapFavoritePageButton();
await pumpAndSettle(); await pumpAndSettle();
@ -366,7 +366,7 @@ extension CommonOperations on WidgetTester {
await hoverOnPageName( await hoverOnPageName(
name, name,
layout: layout, layout: layout,
useLast: false, useLast: true,
onHover: () async { onHover: () async {
await tapUnfavoritePageButton(); await tapUnfavoritePageButton();
await pumpAndSettle(); await pumpAndSettle();
@ -397,7 +397,7 @@ extension CommonOperations on WidgetTester {
break; break;
default: default:
} }
await gesture.moveTo(offset); await gesture.moveTo(offset, timeStamp: const Duration(milliseconds: 400));
await gesture.up(); await gesture.up();
await pumpAndSettle(); await pumpAndSettle();
} }

View File

@ -42,7 +42,7 @@ class MenuBloc extends Bloc<MenuEvent, MenuState> {
final result = await _workspaceService.createApp( final result = await _workspaceService.createApp(
name: event.name, name: event.name,
desc: event.desc, desc: event.desc,
index: 0, // default to the first index index: event.index,
); );
result.fold( result.fold(
(app) => emit(state.copyWith(plugin: app.plugin())), (app) => emit(state.copyWith(plugin: app.plugin())),
@ -111,7 +111,8 @@ class MenuBloc extends Bloc<MenuEvent, MenuState> {
class MenuEvent with _$MenuEvent { class MenuEvent with _$MenuEvent {
const factory MenuEvent.initial() = _Initial; const factory MenuEvent.initial() = _Initial;
const factory MenuEvent.openPage(Plugin plugin) = _OpenPage; const factory MenuEvent.openPage(Plugin plugin) = _OpenPage;
const factory MenuEvent.createApp(String name, {String? desc}) = _CreateApp; const factory MenuEvent.createApp(String name, {String? desc, int? index}) =
_CreateApp;
const factory MenuEvent.moveApp(int fromIndex, int toIndex) = _MoveApp; const factory MenuEvent.moveApp(int fromIndex, int toIndex) = _MoveApp;
const factory MenuEvent.didReceiveApps( const factory MenuEvent.didReceiveApps(
Either<List<ViewPB>, FlowyError> appsOrFail, Either<List<ViewPB>, FlowyError> appsOrFail,

View File

@ -125,4 +125,17 @@ extension ViewLayoutExtension on ViewLayoutPB {
throw Exception('Unknown layout type'); throw Exception('Unknown layout type');
} }
} }
bool get isDatabaseView {
switch (this) {
case ViewLayoutPB.Grid:
case ViewLayoutPB.Board:
case ViewLayoutPB.Calendar:
return true;
case ViewLayoutPB.Document:
return false;
default:
throw Exception('Unknown layout type');
}
}
} }

View File

@ -49,6 +49,7 @@ class PersonalFolder extends StatelessWidget {
isFirstChild: view.id == views.first.id, isFirstChild: view.id == views.first.id,
view: view, view: view,
level: 0, level: 0,
leftPadding: 16,
onSelected: (view) { onSelected: (view) {
getIt<TabsBloc>().add( getIt<TabsBloc>().add(
TabsEvent.openPlugin( TabsEvent.openPlugin(
@ -114,6 +115,7 @@ class _PersonalFolderHeaderState extends State<PersonalFolderHeader> {
context.read<MenuBloc>().add( context.read<MenuBloc>().add(
MenuEvent.createApp( MenuEvent.createApp(
LocaleKeys.menuAppHeader_defaultNewPageName.tr(), LocaleKeys.menuAppHeader_defaultNewPageName.tr(),
index: 0,
), ),
); );
widget.onAdded(); widget.onAdded();

View File

@ -48,7 +48,12 @@ class SidebarNewPageButton extends StatelessWidget {
value: '', value: '',
confirm: (value) { confirm: (value) {
if (value.isNotEmpty) { if (value.isNotEmpty) {
context.read<MenuBloc>().add(MenuEvent.createApp(value, desc: '')); context.read<MenuBloc>().add(
MenuEvent.createApp(
value,
desc: '',
),
);
} }
}, },
).show(context); ).show(context);

View File

@ -1,4 +1,5 @@
import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/widgets/draggable_item/draggable_item.dart'; import 'package:appflowy/workspace/presentation/widgets/draggable_item/draggable_item.dart';
import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
@ -70,16 +71,17 @@ class _DraggableViewItemState extends State<DraggableViewItem> {
data: widget.view, data: widget.view,
onWillAccept: (data) => true, onWillAccept: (data) => true,
onMove: (data) { onMove: (data) {
if (!_shouldAccept(data.data)) {
return;
}
final renderBox = context.findRenderObject() as RenderBox; final renderBox = context.findRenderObject() as RenderBox;
final offset = renderBox.globalToLocal(data.offset); final offset = renderBox.globalToLocal(data.offset);
final position = _computeHoverPosition(offset, renderBox.size);
if (!_shouldAccept(data.data, position)) {
return;
}
setState(() { setState(() {
position = _computeHoverPosition(offset, renderBox.size);
Log.debug( Log.debug(
'offset: $offset, position: $position, size: ${renderBox.size}', 'offset: $offset, position: $position, size: ${renderBox.size}',
); );
this.position = position;
}); });
}, },
onLeave: (_) => setState( onLeave: (_) => setState(
@ -102,6 +104,12 @@ class _DraggableViewItemState extends State<DraggableViewItem> {
} }
void _move(ViewPB from, ViewPB to) { void _move(ViewPB from, ViewPB to) {
if (position == DraggableHoverPosition.center &&
to.layout != ViewLayoutPB.Document) {
// not support moving into a database
return;
}
switch (position) { switch (position) {
case DraggableHoverPosition.top: case DraggableHoverPosition.top:
context.read<ViewBloc>().add( context.read<ViewBloc>().add(
@ -136,7 +144,7 @@ class _DraggableViewItemState extends State<DraggableViewItem> {
} }
DraggableHoverPosition _computeHoverPosition(Offset offset, Size size) { DraggableHoverPosition _computeHoverPosition(Offset offset, Size size) {
final threshold = size.height / 4.0; final threshold = size.height / 3.0;
if (widget.isFirstChild && offset.dy < -5.0) { if (widget.isFirstChild && offset.dy < -5.0) {
return DraggableHoverPosition.top; return DraggableHoverPosition.top;
} }
@ -146,7 +154,13 @@ class _DraggableViewItemState extends State<DraggableViewItem> {
return DraggableHoverPosition.center; return DraggableHoverPosition.center;
} }
bool _shouldAccept(ViewPB data) { bool _shouldAccept(ViewPB data, DraggableHoverPosition position) {
// could not move the view to a database
if (widget.view.layout.isDatabaseView &&
position == DraggableHoverPosition.center) {
return false;
}
// ignore moving the view to itself // ignore moving the view to itself
if (data.id == widget.view.id) { if (data.id == widget.view.id) {
return false; return false;

View File

@ -23,6 +23,7 @@ class ViewItem extends StatelessWidget {
const ViewItem({ const ViewItem({
super.key, super.key,
required this.view, required this.view,
this.parentView,
required this.categoryType, required this.categoryType,
required this.level, required this.level,
this.leftPadding = 10, this.leftPadding = 10,
@ -32,6 +33,7 @@ class ViewItem extends StatelessWidget {
}); });
final ViewPB view; final ViewPB view;
final ViewPB? parentView;
final FolderCategoryType categoryType; final FolderCategoryType categoryType;
@ -60,6 +62,7 @@ class ViewItem extends StatelessWidget {
builder: (context, state) { builder: (context, state) {
return InnerViewItem( return InnerViewItem(
view: state.view, view: state.view,
parentView: parentView,
childViews: state.childViews, childViews: state.childViews,
categoryType: categoryType, categoryType: categoryType,
level: level, level: level,
@ -80,18 +83,20 @@ class InnerViewItem extends StatelessWidget {
const InnerViewItem({ const InnerViewItem({
super.key, super.key,
required this.view, required this.view,
required this.parentView,
required this.childViews, required this.childViews,
required this.categoryType, required this.categoryType,
this.isDraggable = true, this.isDraggable = true,
this.isExpanded = true, this.isExpanded = true,
required this.level, required this.level,
this.leftPadding = 10, required this.leftPadding,
required this.showActions, required this.showActions,
required this.onSelected, required this.onSelected,
this.isFirstChild = false, this.isFirstChild = false,
}); });
final ViewPB view; final ViewPB view;
final ViewPB? parentView;
final List<ViewPB> childViews; final List<ViewPB> childViews;
final FolderCategoryType categoryType; final FolderCategoryType categoryType;
@ -109,10 +114,13 @@ class InnerViewItem extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
Widget child = SingleInnerViewItem( Widget child = SingleInnerViewItem(
view: view, view: view,
parentView: parentView,
level: level, level: level,
showActions: showActions, showActions: showActions,
onSelected: onSelected, onSelected: onSelected,
isExpanded: isExpanded, isExpanded: isExpanded,
isDraggable: isDraggable,
leftPadding: leftPadding,
); );
// if the view is expanded and has child views, render its child views // if the view is expanded and has child views, render its child views
@ -120,12 +128,14 @@ class InnerViewItem extends StatelessWidget {
final children = childViews.map((childView) { final children = childViews.map((childView) {
return ViewItem( return ViewItem(
key: ValueKey('${categoryType.name} ${childView.id}'), key: ValueKey('${categoryType.name} ${childView.id}'),
parentView: view,
categoryType: categoryType, categoryType: categoryType,
isFirstChild: childView.id == childViews.first.id, isFirstChild: childView.id == childViews.first.id,
view: childView, view: childView,
level: level + 1, level: level + 1,
onSelected: onSelected, onSelected: onSelected,
isDraggable: isDraggable, isDraggable: isDraggable,
leftPadding: leftPadding,
); );
}).toList(); }).toList();
@ -139,7 +149,7 @@ class InnerViewItem extends StatelessWidget {
} }
// wrap the child with DraggableItem if isDraggable is true // wrap the child with DraggableItem if isDraggable is true
if (isDraggable) { if (isDraggable && !isReferencedDatabaseView(view, parentView)) {
child = DraggableViewItem( child = DraggableViewItem(
isFirstChild: isFirstChild, isFirstChild: isFirstChild,
view: view, view: view,
@ -147,10 +157,12 @@ class InnerViewItem extends StatelessWidget {
feedback: (context) { feedback: (context) {
return ViewItem( return ViewItem(
view: view, view: view,
parentView: parentView,
categoryType: categoryType, categoryType: categoryType,
level: level, level: level,
onSelected: onSelected, onSelected: onSelected,
isDraggable: false, isDraggable: false,
leftPadding: leftPadding,
); );
}, },
); );
@ -170,19 +182,23 @@ class SingleInnerViewItem extends StatefulWidget {
const SingleInnerViewItem({ const SingleInnerViewItem({
super.key, super.key,
required this.view, required this.view,
required this.parentView,
required this.isExpanded, required this.isExpanded,
required this.level, required this.level,
this.leftPadding = 10, required this.leftPadding,
this.isDraggable = true,
required this.showActions, required this.showActions,
required this.onSelected, required this.onSelected,
}); });
final ViewPB view; final ViewPB view;
final ViewPB? parentView;
final bool isExpanded; final bool isExpanded;
final int level; final int level;
final double leftPadding; final double leftPadding;
final bool isDraggable;
final bool showActions; final bool showActions;
final void Function(ViewPB) onSelected; final void Function(ViewPB) onSelected;
@ -200,16 +216,16 @@ class _SingleInnerViewItemState extends State<SingleInnerViewItem> {
buildWhenOnHover: () => !widget.showActions, buildWhenOnHover: () => !widget.showActions,
builder: (_, onHover) => _buildViewItem(onHover), builder: (_, onHover) => _buildViewItem(onHover),
isSelected: () => isSelected: () =>
widget.showActions || widget.isDraggable &&
getIt<MenuSharedState>().latestOpenView?.id == widget.view.id, (widget.showActions ||
getIt<MenuSharedState>().latestOpenView?.id == widget.view.id),
); );
} }
Widget _buildViewItem(bool onHover) { Widget _buildViewItem(bool onHover) {
final children = [ final children = [
// expand icon // expand icon
_buildExpandedIcon(), _buildLeftIcon(),
const HSpace(7),
// icon // icon
SizedBox.square( SizedBox.square(
dimension: 16, dimension: 16,
@ -229,12 +245,15 @@ class _SingleInnerViewItemState extends State<SingleInnerViewItem> {
if (widget.showActions || onHover) { if (widget.showActions || onHover) {
// ··· more action button // ··· more action button
children.add(_buildViewMoreActionButton(context)); children.add(_buildViewMoreActionButton(context));
// only support add button for document layout
if (widget.view.layout == ViewLayoutPB.Document) {
// + button // + button
children.add(_buildViewAddButton(context)); children.add(_buildViewAddButton(context));
} }
}
// Don't use GestureDetector here, because it doesn't response to the tap event sometimes. return GestureDetector(
return InkWell( behavior: HitTestBehavior.translucent,
onTap: () => widget.onSelected(widget.view), onTap: () => widget.onSelected(widget.view),
child: SizedBox( child: SizedBox(
height: 26, height: 26,
@ -248,8 +267,14 @@ class _SingleInnerViewItemState extends State<SingleInnerViewItem> {
); );
} }
// > button // > button or · button
Widget _buildExpandedIcon() { // show > if the view is expandable.
// show · if the view can't contain child views.
Widget _buildLeftIcon() {
if (isReferencedDatabaseView(widget.view, widget.parentView)) {
return const _DotIconWidget();
}
final name = final name =
widget.isExpanded ? 'home/drop_down_show' : 'home/drop_down_hide'; widget.isExpanded ? 'home/drop_down_show' : 'home/drop_down_hide';
return GestureDetector( return GestureDetector(
@ -343,3 +368,30 @@ class _SingleInnerViewItemState extends State<SingleInnerViewItem> {
); );
} }
} }
class _DotIconWidget extends StatelessWidget {
const _DotIconWidget();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(6.0),
child: Container(
width: 4,
height: 4,
decoration: BoxDecoration(
color: Theme.of(context).iconTheme.color,
borderRadius: BorderRadius.circular(2),
),
),
);
}
}
// workaround: we should use view.isEndPoint or something to check if the view can contain child views. But currently, we don't have that field.
bool isReferencedDatabaseView(ViewPB view, ViewPB? parentView) {
if (parentView == null) {
return false;
}
return view.layout.isDatabaseView && parentView.layout.isDatabaseView;
}

View File

@ -32,8 +32,8 @@ void main() {
menuBloc.add(const MenuEvent.createApp("App 3")); menuBloc.add(const MenuEvent.createApp("App 3"));
await blocResponseFuture(); await blocResponseFuture();
assert(menuBloc.state.views[0].name == 'App 3'); assert(menuBloc.state.views[1].name == 'App 1');
assert(menuBloc.state.views[1].name == 'App 2'); assert(menuBloc.state.views[2].name == 'App 2');
assert(menuBloc.state.views[2].name == 'App 1'); assert(menuBloc.state.views[3].name == 'App 3');
}); });
} }