diff --git a/frontend/appflowy_flutter/integration_test/sidebar/sidebar_test.dart b/frontend/appflowy_flutter/integration_test/sidebar/sidebar_test.dart index 19c1484248..af612f8ee7 100644 --- a/frontend/appflowy_flutter/integration_test/sidebar/sidebar_test.dart +++ b/frontend/appflowy_flutter/integration_test/sidebar/sidebar_test.dart @@ -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/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/view_add_button.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_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; @@ -138,5 +140,70 @@ void main() { .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(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); + }, + ); + }); }); } diff --git a/frontend/appflowy_flutter/integration_test/util/common_operations.dart b/frontend/appflowy_flutter/integration_test/util/common_operations.dart index 5481bdd791..fc00b9b3ef 100644 --- a/frontend/appflowy_flutter/integration_test/util/common_operations.dart +++ b/frontend/appflowy_flutter/integration_test/util/common_operations.dart @@ -351,7 +351,7 @@ extension CommonOperations on WidgetTester { await hoverOnPageName( name, layout: layout, - useLast: false, + useLast: true, onHover: () async { await tapFavoritePageButton(); await pumpAndSettle(); @@ -366,7 +366,7 @@ extension CommonOperations on WidgetTester { await hoverOnPageName( name, layout: layout, - useLast: false, + useLast: true, onHover: () async { await tapUnfavoritePageButton(); await pumpAndSettle(); @@ -397,7 +397,7 @@ extension CommonOperations on WidgetTester { break; default: } - await gesture.moveTo(offset); + await gesture.moveTo(offset, timeStamp: const Duration(milliseconds: 400)); await gesture.up(); await pumpAndSettle(); } diff --git a/frontend/appflowy_flutter/lib/workspace/application/menu/menu_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/menu/menu_bloc.dart index 1764c3a911..84371be2d7 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/menu/menu_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/menu/menu_bloc.dart @@ -42,7 +42,7 @@ class MenuBloc extends Bloc { final result = await _workspaceService.createApp( name: event.name, desc: event.desc, - index: 0, // default to the first index + index: event.index, ); result.fold( (app) => emit(state.copyWith(plugin: app.plugin())), @@ -111,7 +111,8 @@ class MenuBloc extends Bloc { class MenuEvent with _$MenuEvent { const factory MenuEvent.initial() = _Initial; 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.didReceiveApps( Either, FlowyError> appsOrFail, diff --git a/frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart b/frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart index b0be52ae4d..aef001209b 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart @@ -125,4 +125,17 @@ extension ViewLayoutExtension on ViewLayoutPB { 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'); + } + } } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/folder/personal_folder.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/folder/personal_folder.dart index 31dd28fa94..798ebe7078 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/folder/personal_folder.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/folder/personal_folder.dart @@ -49,6 +49,7 @@ class PersonalFolder extends StatelessWidget { isFirstChild: view.id == views.first.id, view: view, level: 0, + leftPadding: 16, onSelected: (view) { getIt().add( TabsEvent.openPlugin( @@ -114,6 +115,7 @@ class _PersonalFolderHeaderState extends State { context.read().add( MenuEvent.createApp( LocaleKeys.menuAppHeader_defaultNewPageName.tr(), + index: 0, ), ); widget.onAdded(); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_new_page_button.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_new_page_button.dart index 8bce5d12ca..a5bcfede65 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_new_page_button.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_new_page_button.dart @@ -48,7 +48,12 @@ class SidebarNewPageButton extends StatelessWidget { value: '', confirm: (value) { if (value.isNotEmpty) { - context.read().add(MenuEvent.createApp(value, desc: '')); + context.read().add( + MenuEvent.createApp( + value, + desc: '', + ), + ); } }, ).show(context); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/draggable_view_item.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/draggable_view_item.dart index 1929f9e407..b0e7be297a 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/draggable_view_item.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/draggable_view_item.dart @@ -1,4 +1,5 @@ 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_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; @@ -70,16 +71,17 @@ class _DraggableViewItemState extends State { data: widget.view, onWillAccept: (data) => true, onMove: (data) { - if (!_shouldAccept(data.data)) { - return; - } final renderBox = context.findRenderObject() as RenderBox; final offset = renderBox.globalToLocal(data.offset); + final position = _computeHoverPosition(offset, renderBox.size); + if (!_shouldAccept(data.data, position)) { + return; + } setState(() { - position = _computeHoverPosition(offset, renderBox.size); Log.debug( 'offset: $offset, position: $position, size: ${renderBox.size}', ); + this.position = position; }); }, onLeave: (_) => setState( @@ -102,6 +104,12 @@ class _DraggableViewItemState extends State { } void _move(ViewPB from, ViewPB to) { + if (position == DraggableHoverPosition.center && + to.layout != ViewLayoutPB.Document) { + // not support moving into a database + return; + } + switch (position) { case DraggableHoverPosition.top: context.read().add( @@ -136,7 +144,7 @@ class _DraggableViewItemState extends State { } 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) { return DraggableHoverPosition.top; } @@ -146,7 +154,13 @@ class _DraggableViewItemState extends State { 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 if (data.id == widget.view.id) { return false; diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_item.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_item.dart index 687b41a3f3..fab322006d 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_item.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_item.dart @@ -23,6 +23,7 @@ class ViewItem extends StatelessWidget { const ViewItem({ super.key, required this.view, + this.parentView, required this.categoryType, required this.level, this.leftPadding = 10, @@ -32,6 +33,7 @@ class ViewItem extends StatelessWidget { }); final ViewPB view; + final ViewPB? parentView; final FolderCategoryType categoryType; @@ -60,6 +62,7 @@ class ViewItem extends StatelessWidget { builder: (context, state) { return InnerViewItem( view: state.view, + parentView: parentView, childViews: state.childViews, categoryType: categoryType, level: level, @@ -80,18 +83,20 @@ class InnerViewItem extends StatelessWidget { const InnerViewItem({ super.key, required this.view, + required this.parentView, required this.childViews, required this.categoryType, this.isDraggable = true, this.isExpanded = true, required this.level, - this.leftPadding = 10, + required this.leftPadding, required this.showActions, required this.onSelected, this.isFirstChild = false, }); final ViewPB view; + final ViewPB? parentView; final List childViews; final FolderCategoryType categoryType; @@ -109,10 +114,13 @@ class InnerViewItem extends StatelessWidget { Widget build(BuildContext context) { Widget child = SingleInnerViewItem( view: view, + parentView: parentView, level: level, showActions: showActions, onSelected: onSelected, isExpanded: isExpanded, + isDraggable: isDraggable, + leftPadding: leftPadding, ); // 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) { return ViewItem( key: ValueKey('${categoryType.name} ${childView.id}'), + parentView: view, categoryType: categoryType, isFirstChild: childView.id == childViews.first.id, view: childView, level: level + 1, onSelected: onSelected, isDraggable: isDraggable, + leftPadding: leftPadding, ); }).toList(); @@ -139,7 +149,7 @@ class InnerViewItem extends StatelessWidget { } // wrap the child with DraggableItem if isDraggable is true - if (isDraggable) { + if (isDraggable && !isReferencedDatabaseView(view, parentView)) { child = DraggableViewItem( isFirstChild: isFirstChild, view: view, @@ -147,10 +157,12 @@ class InnerViewItem extends StatelessWidget { feedback: (context) { return ViewItem( view: view, + parentView: parentView, categoryType: categoryType, level: level, onSelected: onSelected, isDraggable: false, + leftPadding: leftPadding, ); }, ); @@ -170,19 +182,23 @@ class SingleInnerViewItem extends StatefulWidget { const SingleInnerViewItem({ super.key, required this.view, + required this.parentView, required this.isExpanded, required this.level, - this.leftPadding = 10, + required this.leftPadding, + this.isDraggable = true, required this.showActions, required this.onSelected, }); final ViewPB view; + final ViewPB? parentView; final bool isExpanded; final int level; final double leftPadding; + final bool isDraggable; final bool showActions; final void Function(ViewPB) onSelected; @@ -200,16 +216,16 @@ class _SingleInnerViewItemState extends State { buildWhenOnHover: () => !widget.showActions, builder: (_, onHover) => _buildViewItem(onHover), isSelected: () => - widget.showActions || - getIt().latestOpenView?.id == widget.view.id, + widget.isDraggable && + (widget.showActions || + getIt().latestOpenView?.id == widget.view.id), ); } Widget _buildViewItem(bool onHover) { final children = [ // expand icon - _buildExpandedIcon(), - const HSpace(7), + _buildLeftIcon(), // icon SizedBox.square( dimension: 16, @@ -229,12 +245,15 @@ class _SingleInnerViewItemState extends State { if (widget.showActions || onHover) { // ··· more action button children.add(_buildViewMoreActionButton(context)); - // + button - children.add(_buildViewAddButton(context)); + // only support add button for document layout + if (widget.view.layout == ViewLayoutPB.Document) { + // + button + children.add(_buildViewAddButton(context)); + } } - // Don't use GestureDetector here, because it doesn't response to the tap event sometimes. - return InkWell( + return GestureDetector( + behavior: HitTestBehavior.translucent, onTap: () => widget.onSelected(widget.view), child: SizedBox( height: 26, @@ -248,8 +267,14 @@ class _SingleInnerViewItemState extends State { ); } - // > button - Widget _buildExpandedIcon() { + // > button or · button + // 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 = widget.isExpanded ? 'home/drop_down_show' : 'home/drop_down_hide'; return GestureDetector( @@ -343,3 +368,30 @@ class _SingleInnerViewItemState extends State { ); } } + +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; +} diff --git a/frontend/appflowy_flutter/test/bloc_test/home_test/menu_bloc_test.dart b/frontend/appflowy_flutter/test/bloc_test/home_test/menu_bloc_test.dart index 5d28a0e103..abbdeaa505 100644 --- a/frontend/appflowy_flutter/test/bloc_test/home_test/menu_bloc_test.dart +++ b/frontend/appflowy_flutter/test/bloc_test/home_test/menu_bloc_test.dart @@ -32,8 +32,8 @@ void main() { menuBloc.add(const MenuEvent.createApp("App 3")); await blocResponseFuture(); - assert(menuBloc.state.views[0].name == 'App 3'); - assert(menuBloc.state.views[1].name == 'App 2'); - assert(menuBloc.state.views[2].name == 'App 1'); + assert(menuBloc.state.views[1].name == 'App 1'); + assert(menuBloc.state.views[2].name == 'App 2'); + assert(menuBloc.state.views[3].name == 'App 3'); }); }