feat: support changing the workspace icon (#4898)

This commit is contained in:
Lucas.Xu 2024-03-15 13:10:30 +07:00 committed by GitHub
parent 8776ac5c36
commit ac34617e51
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 269 additions and 42 deletions

View File

@ -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<void> main() async {
preset_af_cloud_env_test.main();
@ -16,5 +19,7 @@ Future<void> main() async {
anon_user_continue_test.main();
// workspace
collaboration_workspace_test.main();
change_workspace_name_and_icon_test.main();
}

View File

@ -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<WorkspaceIcon>(
find.byType(WorkspaceIcon),
);
expect(workspaceIcon.workspace.icon, '');
await tester.openWorkspaceMenu();
await tester.changeWorkspaceIcon(icon);
await tester.changeWorkspaceName(name);
workspaceIcon = tester.widget<WorkspaceIcon>(
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<WorkspaceIcon>(
find.byType(WorkspaceIcon),
);
expect(workspaceIcon.workspace.icon, icon);
expect(workspaceIcon.workspace.name, name);
});
}

View File

@ -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();

View File

@ -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<void> 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<void> openWorkspace(String name) async {
final workspace = find.descendant(
of: find.byType(WorkspaceMenuItem),
matching: find.findTextInFlowyText(name),
);
expect(workspace, findsOneWidget);
await tapButton(workspace);
}
Future<void> 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<void> 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));
}
}

View File

@ -202,7 +202,7 @@ class UserWorkspaceBloc extends Bloc<UserWorkspaceEvent, UserWorkspaceState> {
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;

View File

@ -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<SidebarWorkspaceWrapper> createState() =>
_SidebarWorkspaceWrapperState();
}
class _WorkspaceWrapperState extends State<_WorkspaceWrapper> {
class _SidebarWorkspaceWrapperState extends State<SidebarWorkspaceWrapper> {
@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,

View File

@ -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<WorkspaceIcon> createState() => _WorkspaceIconState();
}
class _WorkspaceIconState extends State<WorkspaceIcon> {
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<UserWorkspaceBloc>().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,
),
);
}

View File

@ -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,
),
),
),

View File

@ -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",

View File

@ -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)

View File

@ -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]

View File

@ -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| {