feat: open workspace, rename workspace and update workspace icon (#4818)

* feat: support opening a workspace

* feat: support renaming a workspace

* fix: rename issue

* fix: rename issues

* feat: refactor menu bloc

* test: add collaborative workspace test

* test: add open workspace integration tet

* test: add delete workspace integration tet

* chore: turn off collab workspace feature
This commit is contained in:
Lucas.Xu
2024-03-05 13:51:03 +08:00
committed by GitHub
parent c0210a5778
commit c8e86f4f26
27 changed files with 946 additions and 367 deletions

View File

@ -1,139 +0,0 @@
import 'dart:async';
import 'package:appflowy/workspace/application/workspace/workspace_listener.dart';
import 'package:appflowy/workspace/application/workspace/workspace_service.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'menu_bloc.freezed.dart';
class MenuBloc extends Bloc<MenuEvent, MenuState> {
MenuBloc({required this.user, required this.workspaceId})
: _workspaceService = WorkspaceService(workspaceId: workspaceId),
_listener = WorkspaceListener(
user: user,
workspaceId: workspaceId,
),
super(MenuState.initial()) {
_dispatch();
}
final WorkspaceService _workspaceService;
final WorkspaceListener _listener;
final UserProfilePB user;
final String workspaceId;
@override
Future<void> close() async {
await _listener.stop();
return super.close();
}
void _dispatch() {
on<MenuEvent>(
(event, emit) async {
await event.map(
initial: (e) async {
_listener.start(appsChanged: _handleAppsOrFail);
await _fetchApps(emit);
},
createApp: (_CreateApp event) async {
final result = await _workspaceService.createApp(
name: event.name,
desc: event.desc,
index: event.index,
);
result.fold(
(app) => emit(state.copyWith(lastCreatedView: app)),
(error) {
Log.error(error);
emit(
state.copyWith(
successOrFailure: FlowyResult.failure(error),
),
);
},
);
},
didReceiveApps: (e) async {
emit(
e.appsOrFail.fold(
(views) => state.copyWith(
views: views,
successOrFailure: FlowyResult.success(null),
),
(err) =>
state.copyWith(successOrFailure: FlowyResult.failure(err)),
),
);
},
moveApp: (_MoveApp value) {
if (state.views.length > value.fromIndex) {
final view = state.views[value.fromIndex];
_workspaceService.moveApp(
appId: view.id,
fromIndex: value.fromIndex,
toIndex: value.toIndex,
);
final apps = List<ViewPB>.from(state.views);
apps.insert(value.toIndex, apps.removeAt(value.fromIndex));
emit(state.copyWith(views: apps));
}
},
);
},
);
}
// ignore: unused_element
Future<void> _fetchApps(Emitter<MenuState> emit) async {
final viewsOrError = await _workspaceService.getViews();
emit(
viewsOrError.fold(
(views) => state.copyWith(views: views),
(error) {
Log.error(error);
return state.copyWith(successOrFailure: FlowyResult.failure(error));
},
),
);
}
void _handleAppsOrFail(FlowyResult<List<ViewPB>, FlowyError> appsOrFail) {
appsOrFail.fold(
(apps) => add(MenuEvent.didReceiveApps(FlowyResult.success(apps))),
(error) => add(MenuEvent.didReceiveApps(FlowyResult.failure(error))),
);
}
}
@freezed
class MenuEvent with _$MenuEvent {
const factory MenuEvent.initial() = _Initial;
const factory MenuEvent.createApp(String name, {String? desc, int? index}) =
_CreateApp;
const factory MenuEvent.moveApp(int fromIndex, int toIndex) = _MoveApp;
const factory MenuEvent.didReceiveApps(
FlowyResult<List<ViewPB>, FlowyError> appsOrFail,
) = _ReceiveApps;
}
@freezed
class MenuState with _$MenuState {
const factory MenuState({
required List<ViewPB> views,
required FlowyResult<void, FlowyError> successOrFailure,
ViewPB? lastCreatedView,
}) = _MenuState;
factory MenuState.initial() => MenuState(
views: [],
successOrFailure: FlowyResult.success(null),
);
}

View File

@ -1,2 +1,2 @@
export 'menu_bloc.dart';
export 'menu_user_bloc.dart';
export 'sidebar_root_views_bloc.dart';

View File

@ -0,0 +1,160 @@
import 'dart:async';
import 'package:appflowy/workspace/application/workspace/workspace_listener.dart';
import 'package:appflowy/workspace/application/workspace/workspace_service.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'sidebar_root_views_bloc.freezed.dart';
class SidebarRootViewsBloc
extends Bloc<SidebarRootViewsEvent, SidebarRootViewState> {
SidebarRootViewsBloc() : super(SidebarRootViewState.initial()) {
_dispatch();
}
late WorkspaceService _workspaceService;
WorkspaceListener? _listener;
@override
Future<void> close() async {
await _listener?.stop();
return super.close();
}
void _dispatch() {
on<SidebarRootViewsEvent>(
(event, emit) async {
await event.when(
initial: (userProfile, workspaceId) async {
_initial(userProfile, workspaceId);
await _fetchApps(emit);
},
reset: (userProfile, workspaceId) async {
await _listener?.stop();
_initial(userProfile, workspaceId);
await _fetchApps(emit);
},
createRootView: (name, desc, index) async {
final result = await _workspaceService.createApp(
name: name,
desc: desc,
index: index,
);
result.fold(
(view) => emit(state.copyWith(lastCreatedRootView: view)),
(error) {
Log.error(error);
emit(
state.copyWith(
successOrFailure: FlowyResult.failure(error),
),
);
},
);
},
didReceiveViews: (viewsOrFailure) async {
emit(
viewsOrFailure.fold(
(views) => state.copyWith(
views: views,
successOrFailure: FlowyResult.success(null),
),
(err) =>
state.copyWith(successOrFailure: FlowyResult.failure(err)),
),
);
},
moveRootView: (int fromIndex, int toIndex) {
if (state.views.length > fromIndex) {
final view = state.views[fromIndex];
_workspaceService.moveApp(
appId: view.id,
fromIndex: fromIndex,
toIndex: toIndex,
);
final views = List<ViewPB>.from(state.views);
views.insert(toIndex, views.removeAt(fromIndex));
emit(state.copyWith(views: views));
}
},
);
},
);
}
Future<void> _fetchApps(Emitter<SidebarRootViewState> emit) async {
final viewsOrError = await _workspaceService.getViews();
emit(
viewsOrError.fold(
(views) => state.copyWith(views: views),
(error) {
Log.error(error);
return state.copyWith(successOrFailure: FlowyResult.failure(error));
},
),
);
}
void _handleAppsOrFail(FlowyResult<List<ViewPB>, FlowyError> viewsOrFail) {
viewsOrFail.fold(
(views) => add(
SidebarRootViewsEvent.didReceiveViews(FlowyResult.success(views)),
),
(error) => add(
SidebarRootViewsEvent.didReceiveViews(FlowyResult.failure(error)),
),
);
}
void _initial(UserProfilePB userProfile, String workspaceId) {
_workspaceService = WorkspaceService(workspaceId: workspaceId);
_listener = WorkspaceListener(
user: userProfile,
workspaceId: workspaceId,
)..start(appsChanged: _handleAppsOrFail);
}
}
@freezed
class SidebarRootViewsEvent with _$SidebarRootViewsEvent {
const factory SidebarRootViewsEvent.initial(
UserProfilePB userProfile,
String workspaceId,
) = _Initial;
const factory SidebarRootViewsEvent.reset(
UserProfilePB userProfile,
String workspaceId,
) = _Reset;
const factory SidebarRootViewsEvent.createRootView(
String name, {
String? desc,
int? index,
}) = _createRootView;
const factory SidebarRootViewsEvent.moveRootView(int fromIndex, int toIndex) =
_MoveRootView;
const factory SidebarRootViewsEvent.didReceiveViews(
FlowyResult<List<ViewPB>, FlowyError> appsOrFail,
) = _ReceiveApps;
}
@freezed
class SidebarRootViewState with _$SidebarRootViewState {
const factory SidebarRootViewState({
required List<ViewPB> views,
required FlowyResult<void, FlowyError> successOrFailure,
@Default(null) ViewPB? lastCreatedRootView,
}) = _SidebarRootViewState;
factory SidebarRootViewState.initial() => SidebarRootViewState(
views: [],
successOrFailure: FlowyResult.success(null),
);
}

View File

@ -1,10 +1,13 @@
import 'package:appflowy/user/application/user_service.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:protobuf/protobuf.dart';
part 'user_workspace_bloc.freezed.dart';
@ -33,43 +36,207 @@ class UserWorkspaceBloc extends Bloc<UserWorkspaceEvent, UserWorkspaceState> {
},
createWorkspace: (name, desc) async {
final result = await _userService.createUserWorkspace(name);
final (workspaces, createWorkspaceResult) = result.fold(
(s) {
final workspaces = [...state.workspaces, s];
return (
workspaces,
FlowyResult<void, FlowyError>.success(null)
);
},
(e) {
Log.error(e);
return (state.workspaces, FlowyResult.failure(e));
},
);
emit(
state.copyWith(
openWorkspaceResult: null,
deleteWorkspaceResult: null,
createWorkspaceResult:
result.fold((s) => FlowyResult.success(null), (e) {
Log.error(e);
return FlowyResult.failure(e);
}),
updateWorkspaceIconResult: null,
createWorkspaceResult: createWorkspaceResult,
workspaces: workspaces,
),
);
},
deleteWorkspace: (workspaceId) async {
if (state.workspaces.length <= 1) {
// do not allow to delete the last workspace
return emit(
state.copyWith(
openWorkspaceResult: null,
createWorkspaceResult: null,
updateWorkspaceIconResult: null,
renameWorkspaceResult: null,
deleteWorkspaceResult: FlowyResult.failure(
FlowyError(
code: ErrorCode.Internal,
msg: 'Cannot delete the last workspace',
),
),
),
);
}
final result = await _userService.deleteWorkspaceById(workspaceId);
final (workspaces, deleteWorkspaceResult) = result.fold(
(s) {
// if the current workspace is deleted, open the first workspace
if (state.currentWorkspace?.workspaceId == workspaceId) {
add(OpenWorkspace(state.workspaces.first.workspaceId));
}
// remove the deleted workspace from the list instead of fetching
// the workspaces again
final workspaces = [...state.workspaces]..removeWhere(
(e) => e.workspaceId == workspaceId,
);
return (
workspaces,
FlowyResult<void, FlowyError>.success(null)
);
},
(e) {
Log.error(e);
return (state.workspaces, FlowyResult.failure(e));
},
);
emit(
state.copyWith(
openWorkspaceResult: null,
createWorkspaceResult: null,
deleteWorkspaceResult:
result.fold((s) => FlowyResult.success(null), (e) {
Log.error(e);
return FlowyResult.failure(e);
}),
updateWorkspaceIconResult: null,
renameWorkspaceResult: null,
deleteWorkspaceResult: deleteWorkspaceResult,
workspaces: workspaces,
),
);
},
openWorkspace: (workspaceId) async {
final result = await _userService.openWorkspace(workspaceId);
final (currentWorkspace, openWorkspaceResult) =
await _userService.openWorkspace(workspaceId).fold(
(s) {
final openedWorkspace = state.workspaces.firstWhere(
(e) => e.workspaceId == workspaceId,
);
return (
openedWorkspace,
FlowyResult<void, FlowyError>.success(null)
);
},
(f) {
Log.error(f);
return (state.currentWorkspace, FlowyResult.failure(f));
},
);
emit(
state.copyWith(
createWorkspaceResult: null,
deleteWorkspaceResult: null,
openWorkspaceResult:
result.fold((s) => FlowyResult.success(null), (e) {
Log.error(e);
return FlowyResult.failure(e);
}),
updateWorkspaceIconResult: null,
openWorkspaceResult: openWorkspaceResult,
currentWorkspace: currentWorkspace,
),
);
},
renameWorkspace: (workspaceId, name) async {
final result = await _userService.renameWorkspace(
workspaceId,
name,
);
final (workspaces, currentWorkspace, renameWorkspaceResult) =
result.fold(
(s) {
final workspaces = state.workspaces.map((e) {
if (e.workspaceId == workspaceId) {
e.freeze();
return e.rebuild((p0) {
p0.name = name;
});
}
return e;
}).toList();
final currentWorkspace = workspaces.firstWhere(
(e) => e.workspaceId == state.currentWorkspace?.workspaceId,
);
return (
workspaces,
currentWorkspace,
FlowyResult<void, FlowyError>.success(null),
);
},
(e) {
Log.error(e);
return (
state.workspaces,
state.currentWorkspace,
FlowyResult.failure(e),
);
},
);
emit(
state.copyWith(
createWorkspaceResult: null,
deleteWorkspaceResult: null,
openWorkspaceResult: null,
updateWorkspaceIconResult: null,
workspaces: workspaces,
currentWorkspace: currentWorkspace,
renameWorkspaceResult: renameWorkspaceResult,
),
);
},
updateWorkspaceIcon: (workspaceId, icon) async {
final result = await _userService.updateWorkspaceIcon(
workspaceId,
icon,
);
final (workspaces, currentWorkspace, updateWorkspaceIconResult) =
result.fold(
(s) {
final workspaces = state.workspaces.map((e) {
if (e.workspaceId == workspaceId) {
e.freeze();
return e.rebuild((p0) {
// TODO(Lucas): the icon is not ready in the backend
});
}
return e;
}).toList();
final currentWorkspace = workspaces.firstWhere(
(e) => e.workspaceId == state.currentWorkspace?.workspaceId,
);
return (
workspaces,
currentWorkspace,
FlowyResult<void, FlowyError>.success(null),
);
},
(e) {
Log.error(e);
return (
state.workspaces,
state.currentWorkspace,
FlowyResult.failure(e),
);
},
);
emit(
state.copyWith(
createWorkspaceResult: null,
deleteWorkspaceResult: null,
openWorkspaceResult: null,
renameWorkspaceResult: null,
updateWorkspaceIconResult: updateWorkspaceIconResult,
workspaces: workspaces,
currentWorkspace: currentWorkspace,
),
);
},
@ -83,24 +250,17 @@ class UserWorkspaceBloc extends Bloc<UserWorkspaceEvent, UserWorkspaceState> {
Future<(UserWorkspacePB currentWorkspace, List<UserWorkspacePB> workspaces)?>
_fetchWorkspaces() async {
final result = await _userService.getCurrentWorkspace();
return result.fold((currentWorkspace) async {
final result = await _userService.getWorkspaces();
return result.fold((workspaces) {
return (
workspaces.firstWhere(
(e) => e.workspaceId == currentWorkspace.id,
),
workspaces
);
}, (e) {
Log.error(e);
return null;
});
}, (e) {
try {
final currentWorkspace =
await _userService.getCurrentWorkspace().getOrThrow();
final workspaces = await _userService.getWorkspaces().getOrThrow();
final currentWorkspaceInList =
workspaces.firstWhere((e) => e.workspaceId == currentWorkspace.id);
return (currentWorkspaceInList, workspaces);
} catch (e) {
Log.error(e);
return null;
});
}
}
}
@ -114,6 +274,14 @@ class UserWorkspaceEvent with _$UserWorkspaceEvent {
DeleteWorkspace;
const factory UserWorkspaceEvent.openWorkspace(String workspaceId) =
OpenWorkspace;
const factory UserWorkspaceEvent.renameWorkspace(
String workspaceId,
String name,
) = _RenameWorkspace;
const factory UserWorkspaceEvent.updateWorkspaceIcon(
String workspaceId,
String icon,
) = _UpdateWorkspaceIcon;
const factory UserWorkspaceEvent.workspacesReceived(
FlowyResult<List<UserWorkspacePB>, FlowyError> workspacesOrFail,
) = WorkspacesReceived;
@ -127,8 +295,10 @@ class UserWorkspaceState with _$UserWorkspaceState {
@Default(null) FlowyResult<void, FlowyError>? createWorkspaceResult,
@Default(null) FlowyResult<void, FlowyError>? deleteWorkspaceResult,
@Default(null) FlowyResult<void, FlowyError>? openWorkspaceResult,
@Default(null) FlowyResult<void, FlowyError>? renameWorkspaceResult,
@Default(null) FlowyResult<void, FlowyError>? updateWorkspaceIconResult,
}) = _UserWorkspaceState;
factory UserWorkspaceState.initial() =>
factory UserWorkspaceState.initial() =>
const UserWorkspaceState(currentWorkspace: null, workspaces: []);
}