diff --git a/frontend/appflowy_flutter/integration_test/cloud/cloud_runner.dart b/frontend/appflowy_flutter/integration_test/cloud/cloud_runner.dart index 2435d15c3d..c66cdd5cc1 100644 --- a/frontend/appflowy_flutter/integration_test/cloud/cloud_runner.dart +++ b/frontend/appflowy_flutter/integration_test/cloud/cloud_runner.dart @@ -1,9 +1,12 @@ import 'anon_user_continue_test.dart' as anon_user_continue_test; import 'appflowy_cloud_auth_test.dart' as appflowy_cloud_auth_test; -import 'collaborative_workspace_test.dart' as collaboration_workspace_test; import 'empty_test.dart' as preset_af_cloud_env_test; // import 'document_sync_test.dart' as document_sync_test; import 'user_setting_sync_test.dart' as user_sync_test; +import 'workspace/change_name_and_icon_test.dart' + as change_workspace_name_and_icon_test; +import 'workspace/collaborative_workspace_test.dart' + as collaboration_workspace_test; Future main() async { preset_af_cloud_env_test.main(); @@ -16,5 +19,7 @@ Future main() async { anon_user_continue_test.main(); + // workspace collaboration_workspace_test.main(); + change_workspace_name_and_icon_test.main(); } diff --git a/frontend/appflowy_flutter/integration_test/cloud/workspace/change_name_and_icon_test.dart b/frontend/appflowy_flutter/integration_test/cloud/workspace/change_name_and_icon_test.dart new file mode 100644 index 0000000000..b19de8059f --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/cloud/workspace/change_name_and_icon_test.dart @@ -0,0 +1,79 @@ +// ignore_for_file: unused_import + +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/shared/feature_flags.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/auth/af_cloud_mock_auth_service.dart'; +import 'package:appflowy/user/application/auth/auth_service.dart'; +import 'package:appflowy/workspace/application/settings/prelude.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/settings_user_view.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/uuid.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:path/path.dart' as p; + +import '../../shared/mock/mock_file_picker.dart'; +import '../../shared/util.dart'; +import '../../shared/workspace.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + const icon = '😄'; + const name = 'AppFlowy'; + final email = '${uuid()}@appflowy.io'; + + testWidgets('change name and icon', (tester) async { + await tester.initializeAppFlowy( + cloudType: AuthenticatorType.appflowyCloudSelfHost, + email: email, // use the same email to check the next test + ); + + // turn on the collaborative workspace feature flag before testing, + // if the feature is released to the public, this step can be removed + await FeatureFlag.collaborativeWorkspace.turnOn(); + + await tester.tapGoogleLoginInButton(); + await tester.expectToSeeHomePageWithGetStartedPage(); + + var workspaceIcon = tester.widget( + find.byType(WorkspaceIcon), + ); + expect(workspaceIcon.workspace.icon, ''); + + await tester.openWorkspaceMenu(); + await tester.changeWorkspaceIcon(icon); + await tester.changeWorkspaceName(name); + + workspaceIcon = tester.widget( + find.byType(WorkspaceIcon), + ); + expect(workspaceIcon.workspace.icon, icon); + expect(find.findTextInFlowyText(name), findsOneWidget); + }); + + testWidgets('verify the result again after relaunching', (tester) async { + await tester.initializeAppFlowy( + cloudType: AuthenticatorType.appflowyCloudSelfHost, + email: email, // use the same email to check the next test + ); + + // turn on the collaborative workspace feature flag before testing, + // if the feature is released to the public, this step can be removed + await FeatureFlag.collaborativeWorkspace.turnOn(); + + await tester.tapGoogleLoginInButton(); + await tester.expectToSeeHomePageWithGetStartedPage(); + + // check the result again + final workspaceIcon = tester.widget( + find.byType(WorkspaceIcon), + ); + expect(workspaceIcon.workspace.icon, icon); + expect(workspaceIcon.workspace.name, name); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/cloud/collaborative_workspace_test.dart b/frontend/appflowy_flutter/integration_test/cloud/workspace/collaborative_workspace_test.dart similarity index 95% rename from frontend/appflowy_flutter/integration_test/cloud/collaborative_workspace_test.dart rename to frontend/appflowy_flutter/integration_test/cloud/workspace/collaborative_workspace_test.dart index 56d9f2bb2c..856d68f0c1 100644 --- a/frontend/appflowy_flutter/integration_test/cloud/collaborative_workspace_test.dart +++ b/frontend/appflowy_flutter/integration_test/cloud/workspace/collaborative_workspace_test.dart @@ -23,11 +23,11 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:path/path.dart' as p; -import '../shared/database_test_op.dart'; -import '../shared/dir.dart'; -import '../shared/emoji.dart'; -import '../shared/mock/mock_file_picker.dart'; -import '../shared/util.dart'; +import '../../shared/database_test_op.dart'; +import '../../shared/dir.dart'; +import '../../shared/emoji.dart'; +import '../../shared/mock/mock_file_picker.dart'; +import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); diff --git a/frontend/appflowy_flutter/integration_test/shared/workspace.dart b/frontend/appflowy_flutter/integration_test/shared/workspace.dart new file mode 100644 index 0000000000..a153621b80 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/shared/workspace.dart @@ -0,0 +1,58 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/base/icon/icon_picker.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_workspace.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_actions.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'base.dart'; + +extension AppFlowyWorkspace on WidgetTester { + /// Open workspace menu + Future openWorkspaceMenu() async { + final workspaceWrapper = find.byType(SidebarWorkspaceWrapper); + expect(workspaceWrapper, findsOneWidget); + await tapButton(workspaceWrapper); + final workspaceMenu = find.byType(WorkspacesMenu); + expect(workspaceMenu, findsOneWidget); + } + + /// Open a workspace + Future openWorkspace(String name) async { + final workspace = find.descendant( + of: find.byType(WorkspaceMenuItem), + matching: find.findTextInFlowyText(name), + ); + expect(workspace, findsOneWidget); + await tapButton(workspace); + } + + Future changeWorkspaceName(String name) async { + final moreButton = find.descendant( + of: find.byType(WorkspaceMenuItem), + matching: find.byType(WorkspaceMoreActionList), + ); + expect(moreButton, findsOneWidget); + await tapButton(moreButton); + await tapButton(find.findTextInFlowyText(LocaleKeys.button_rename.tr())); + final input = find.byType(TextFormField); + expect(input, findsOneWidget); + await enterText(input, name); + await tapButton(find.text(LocaleKeys.button_ok.tr())); + } + + Future changeWorkspaceIcon(String icon) async { + final iconButton = find.descendant( + of: find.byType(WorkspaceMenuItem), + matching: find.byType(WorkspaceIcon), + ); + expect(iconButton, findsOneWidget); + await tapButton(iconButton); + final iconPicker = find.byType(FlowyIconPicker); + expect(iconPicker, findsOneWidget); + await tapButton(find.findTextInFlowyText(icon)); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/user/user_workspace_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/user/user_workspace_bloc.dart index 8d33334c8d..3aa8f0ba88 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/user/user_workspace_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/user/user_workspace_bloc.dart @@ -202,7 +202,7 @@ class UserWorkspaceBloc extends Bloc { if (e.workspaceId == workspaceId) { e.freeze(); return e.rebuild((p0) { - // TODO(Lucas): the icon is not ready in the backend + p0.icon = icon; }); } return e; diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_workspace.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_workspace.dart index 1ac1afbabd..bf2bb16476 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_workspace.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_workspace.dart @@ -39,7 +39,7 @@ class SidebarWorkspace extends StatelessWidget { return Row( children: [ Expanded( - child: _WorkspaceWrapper( + child: SidebarWorkspaceWrapper( userProfile: userProfile, currentWorkspace: currentWorkspace, ), @@ -106,8 +106,9 @@ class SidebarWorkspace extends StatelessWidget { } } -class _WorkspaceWrapper extends StatefulWidget { - const _WorkspaceWrapper({ +class SidebarWorkspaceWrapper extends StatefulWidget { + const SidebarWorkspaceWrapper({ + super.key, required this.userProfile, required this.currentWorkspace, }); @@ -116,10 +117,11 @@ class _WorkspaceWrapper extends StatefulWidget { final UserProfilePB userProfile; @override - State<_WorkspaceWrapper> createState() => _WorkspaceWrapperState(); + State createState() => + _SidebarWorkspaceWrapperState(); } -class _WorkspaceWrapperState extends State<_WorkspaceWrapper> { +class _SidebarWorkspaceWrapperState extends State { @override Widget build(BuildContext context) { if (PlatformExtension.isDesktopOrWeb) { @@ -182,12 +184,16 @@ class _DesktopWorkspaceWrapperState extends State<_DesktopWorkspaceWrapper> { margin: const EdgeInsets.symmetric(vertical: 8), text: Row( children: [ - const HSpace(4.0), - SizedBox( - width: 24.0, - child: WorkspaceIcon(workspace: widget.currentWorkspace), + const HSpace(2.0), + SizedBox.square( + dimension: 28.0, + child: WorkspaceIcon( + workspace: widget.currentWorkspace, + iconSize: 18, + enableEdit: false, + ), ), - const HSpace(8), + const HSpace(4), Expanded( child: FlowyText.medium( widget.currentWorkspace.name, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart index 93d60414bd..ebe53420a5 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart @@ -7,18 +7,53 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -class WorkspaceIcon extends StatelessWidget { +class WorkspaceIcon extends StatefulWidget { const WorkspaceIcon({ super.key, + required this.enableEdit, + required this.iconSize, required this.workspace, }); final UserWorkspacePB workspace; + final double iconSize; + final bool enableEdit; + + @override + State createState() => _WorkspaceIconState(); +} + +class _WorkspaceIconState extends State { + final controller = PopoverController(); @override Widget build(BuildContext context) { + final child = widget.workspace.icon.isNotEmpty + ? FlowyText( + widget.workspace.icon, + textAlign: TextAlign.center, + fontSize: widget.iconSize, + ) + : Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: ColorGenerator.generateColorFromString( + widget.workspace.name, + ), + borderRadius: BorderRadius.circular(4), + ), + margin: const EdgeInsets.all(2), + child: FlowyText( + widget.workspace.name.isEmpty + ? '' + : widget.workspace.name.substring(0, 1), + fontSize: 16, + color: Colors.black, + ), + ); return AppFlowyPopover( offset: const Offset(0, 8), + controller: controller, direction: PopoverDirection.bottomWithLeftAligned, constraints: BoxConstraints.loose(const Size(360, 380)), clickHandler: PopoverClickHandler.gestureDetector, @@ -27,27 +62,17 @@ class WorkspaceIcon extends StatelessWidget { onSelected: (result) { context.read().add( UserWorkspaceEvent.updateWorkspaceIcon( - workspace.workspaceId, + widget.workspace.workspaceId, result.emoji, ), ); + controller.close(); }, ); }, child: MouseRegion( cursor: SystemMouseCursors.click, - child: Container( - alignment: Alignment.center, - decoration: BoxDecoration( - color: ColorGenerator.generateColorFromString(workspace.name), - borderRadius: BorderRadius.circular(4), - ), - child: FlowyText( - workspace.name.isEmpty ? '' : workspace.name.substring(0, 1), - fontSize: 16, - color: Colors.black, - ), - ), + child: child, ), ); } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart index b2ab95638e..5d44ec9df9 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart @@ -158,11 +158,13 @@ class WorkspaceMenuItem extends StatelessWidget { ), ), Positioned( - left: 12, + left: 8, child: SizedBox.square( dimension: 32, child: WorkspaceIcon( workspace: workspace, + iconSize: 26, + enableEdit: true, ), ), ), diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 164c64aa06..a1fa0cc350 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -74,8 +74,8 @@ "openFailed": "Failed to open workspace", "renameSuccess": "Workspace renamed successfully", "renameFailed": "Failed to rename workspace", - "updateIconSuccess": "Workspace reset successfully", - "updateIconFailed": "Failed to reset workspace" + "updateIconSuccess": "Updated workspace icon successfully", + "updateIconFailed": "Updated workspace icon failed" }, "shareAction": { "buttonText": "Share", diff --git a/frontend/rust-lib/event-integration/src/user_event.rs b/frontend/rust-lib/event-integration/src/user_event.rs index 4f8b858d6e..db09b5414c 100644 --- a/frontend/rust-lib/event-integration/src/user_event.rs +++ b/frontend/rust-lib/event-integration/src/user_event.rs @@ -18,10 +18,10 @@ use flowy_server::af_cloud::define::{USER_DEVICE_ID, USER_EMAIL, USER_SIGN_IN_UR use flowy_server_pub::af_cloud_config::AFCloudConfiguration; use flowy_server_pub::AuthenticatorType; use flowy_user::entities::{ - AuthenticatorPB, CloudSettingPB, CreateWorkspacePB, ImportAppFlowyDataPB, OauthSignInPB, - RenameWorkspacePB, RepeatedUserWorkspacePB, SignInUrlPB, SignInUrlPayloadPB, SignUpPayloadPB, - UpdateCloudConfigPB, UpdateUserProfilePayloadPB, UserProfilePB, UserWorkspaceIdPB, - UserWorkspacePB, + AuthenticatorPB, ChangeWorkspaceIconPB, CloudSettingPB, CreateWorkspacePB, ImportAppFlowyDataPB, + OauthSignInPB, RenameWorkspacePB, RepeatedUserWorkspacePB, SignInUrlPB, SignInUrlPayloadPB, + SignUpPayloadPB, UpdateCloudConfigPB, UpdateUserProfilePayloadPB, UserProfilePB, + UserWorkspaceIdPB, UserWorkspacePB, }; use flowy_user::errors::{FlowyError, FlowyResult}; use flowy_user::event_map::UserEvent; @@ -247,6 +247,27 @@ impl EventIntegrationTest { } } + pub async fn change_workspace_icon( + &self, + workspace_id: &str, + new_icon: &str, + ) -> Result<(), FlowyError> { + let payload = ChangeWorkspaceIconPB { + workspace_id: workspace_id.to_owned(), + new_icon: new_icon.to_owned(), + }; + match EventBuilder::new(self.clone()) + .event(UserEvent::ChangeWorkspaceIcon) + .payload(payload) + .async_send() + .await + .error() + { + Some(err) => Err(err), + None => Ok(()), + } + } + pub async fn folder_read_current_workspace(&self) -> WorkspacePB { EventBuilder::new(self.clone()) .event(FolderEvent::ReadCurrentWorkspace) diff --git a/frontend/rust-lib/event-integration/tests/user/af_cloud_test/workspace_test.rs b/frontend/rust-lib/event-integration/tests/user/af_cloud_test/workspace_test.rs index 2b93a97bd5..5a587bc368 100644 --- a/frontend/rust-lib/event-integration/tests/user/af_cloud_test/workspace_test.rs +++ b/frontend/rust-lib/event-integration/tests/user/af_cloud_test/workspace_test.rs @@ -29,18 +29,28 @@ async fn af_cloud_workspace_delete() { } #[tokio::test] -async fn af_cloud_workspace_name_change() { +async fn af_cloud_workspace_change_name_and_icon() { user_localhost_af_cloud().await; let test = EventIntegrationTest::new().await; let user_profile_pb = test.af_cloud_sign_up().await; let workspaces = test.get_all_workspaces().await; let workspace_id = workspaces.items[0].workspace_id.as_str(); + let new_workspace_name = "new_workspace_name".to_string(); + let new_icon = "🚀".to_string(); test - .rename_workspace(workspace_id, "new_workspace_name") + .rename_workspace(workspace_id, &new_workspace_name) .await .expect("failed to rename workspace"); + test + .change_workspace_icon(workspace_id, &new_icon) + .await + .expect("failed to change workspace icon"); let workspaces = get_synced_workspaces(&test, user_profile_pb.id).await; - assert_eq!(workspaces[0].name, "new_workspace_name".to_string()); + assert_eq!(workspaces[0].name, new_workspace_name); + assert_eq!(workspaces[0].icon, new_icon); + let local_workspaces = test.get_all_workspaces().await; + assert_eq!(local_workspaces.items[0].name, new_workspace_name); + assert_eq!(local_workspaces.items[0].icon, new_icon); } #[tokio::test] diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs index f94f955ba9..231f0299ba 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs @@ -179,7 +179,27 @@ impl UserManager { .patch_workspace(workspace_id, new_workspace_name, new_workspace_icon) .await?; - Ok(()) + // save the icon and name to sqlite db + let uid = self.user_id()?; + let conn = self.db_connection(uid)?; + let mut user_workspace = match self.get_user_workspace(uid, workspace_id) { + Some(user_workspace) => user_workspace, + None => { + return Err(FlowyError::record_not_found().with_context(format!( + "Expected to find user workspace with id: {}, but not found", + workspace_id + ))); + }, + }; + + if let Some(new_workspace_name) = new_workspace_name { + user_workspace.name = new_workspace_name.to_string(); + } + if let Some(new_workspace_icon) = new_workspace_icon { + user_workspace.icon = new_workspace_icon.to_string(); + } + + save_user_workspaces(uid, conn, &[user_workspace]) } pub async fn delete_workspace(&self, workspace_id: &str) -> FlowyResult<()> { @@ -312,6 +332,7 @@ pub fn save_user_workspaces( user_workspace_table::name.eq(&user_workspace.name), user_workspace_table::created_at.eq(&user_workspace.created_at), user_workspace_table::database_storage_id.eq(&user_workspace.database_storage_id), + user_workspace_table::icon.eq(&user_workspace.icon), )) .execute(conn) .and_then(|rows| {