feat: turn guest into member (#7958)

* feat: turn guest into member

* feat: handle share results

* fix: clear email textfield after inviting

* test: add turn into member test

* fix: double toasts show when sharing with guest
This commit is contained in:
Lucas 2025-05-22 11:54:12 +08:00 committed by GitHub
parent f5e2e4e915
commit b0a02e6870
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 258 additions and 84 deletions

View File

@ -1,6 +1,8 @@
import 'package:appflowy/features/share_tab/data/models/share_access_level.dart'; import 'package:appflowy/features/share_tab/data/models/share_access_level.dart';
import 'package:appflowy/features/share_tab/data/models/share_role.dart'; import 'package:appflowy/features/share_tab/data/models/share_role.dart';
typedef SharedUsers = List<SharedUser>;
/// Represents a user with a role on a shared page. /// Represents a user with a role on a shared page.
class SharedUser { class SharedUser {
SharedUser({ SharedUser({

View File

@ -9,7 +9,7 @@ import 'share_with_user_repository.dart';
class LocalShareWithUserRepository extends ShareWithUserRepository { class LocalShareWithUserRepository extends ShareWithUserRepository {
LocalShareWithUserRepository(); LocalShareWithUserRepository();
final List<SharedUser> _sharedUsers = [ final SharedUsers _sharedUsers = [
SharedUser( SharedUser(
email: 'lucas.xu@appflowy.io', email: 'lucas.xu@appflowy.io',
name: 'Lucas Xu', name: 'Lucas Xu',
@ -61,7 +61,7 @@ class LocalShareWithUserRepository extends ShareWithUserRepository {
), ),
]; ];
final List<SharedUser> _availableSharedUsers = [ final SharedUsers _availableSharedUsers = [
SharedUser( SharedUser(
email: 'guest_email@appflowy.io', email: 'guest_email@appflowy.io',
name: 'Guest', name: 'Guest',
@ -79,7 +79,7 @@ class LocalShareWithUserRepository extends ShareWithUserRepository {
]; ];
@override @override
Future<FlowyResult<List<SharedUser>, FlowyError>> getSharedUsersInPage({ Future<FlowyResult<SharedUsers, FlowyError>> getSharedUsersInPage({
required String pageId, required String pageId,
}) async { }) async {
return FlowySuccess(_sharedUsers); return FlowySuccess(_sharedUsers);
@ -134,7 +134,7 @@ class LocalShareWithUserRepository extends ShareWithUserRepository {
} }
@override @override
Future<FlowyResult<List<SharedUser>, FlowyError>> getAvailableSharedUsers({ Future<FlowyResult<SharedUsers, FlowyError>> getAvailableSharedUsers({
required String pageId, required String pageId,
}) async { }) async {
return FlowySuccess([ return FlowySuccess([

View File

@ -13,7 +13,7 @@ class RustShareWithUserRepository extends ShareWithUserRepository {
RustShareWithUserRepository(); RustShareWithUserRepository();
@override @override
Future<FlowyResult<List<SharedUser>, FlowyError>> getSharedUsersInPage({ Future<FlowyResult<SharedUsers, FlowyError>> getSharedUsersInPage({
required String pageId, required String pageId,
}) async { }) async {
final request = GetSharedUsersPayloadPB( final request = GetSharedUsersPayloadPB(
@ -90,7 +90,7 @@ class RustShareWithUserRepository extends ShareWithUserRepository {
} }
@override @override
Future<FlowyResult<List<SharedUser>, FlowyError>> getAvailableSharedUsers({ Future<FlowyResult<SharedUsers, FlowyError>> getAvailableSharedUsers({
required String pageId, required String pageId,
}) async { }) async {
// TODO: Implement this // TODO: Implement this

View File

@ -8,12 +8,12 @@ import 'package:appflowy_result/appflowy_result.dart';
/// for the future. /// for the future.
abstract class ShareWithUserRepository { abstract class ShareWithUserRepository {
/// Gets the list of users and their roles for a shared page. /// Gets the list of users and their roles for a shared page.
Future<FlowyResult<List<SharedUser>, FlowyError>> getSharedUsersInPage({ Future<FlowyResult<SharedUsers, FlowyError>> getSharedUsersInPage({
required String pageId, required String pageId,
}); });
/// Gets the list of users that are available to be shared with. /// Gets the list of users that are available to be shared with.
Future<FlowyResult<List<SharedUser>, FlowyError>> getAvailableSharedUsers({ Future<FlowyResult<SharedUsers, FlowyError>> getAvailableSharedUsers({
required String pageId, required String pageId,
}); });

View File

@ -1,5 +1,4 @@
import 'package:appflowy/features/share_tab/data/models/share_access_level.dart'; import 'package:appflowy/features/share_tab/data/models/models.dart';
import 'package:appflowy/features/share_tab/data/models/shared_user.dart';
import 'package:appflowy/features/share_tab/data/repositories/share_with_user_repository.dart'; import 'package:appflowy/features/share_tab/data/repositories/share_with_user_repository.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/plugins/shared/share/constants.dart'; import 'package:appflowy/plugins/shared/share/constants.dart';
@ -27,6 +26,7 @@ class ShareWithUserBloc extends Bloc<ShareWithUserEvent, ShareWithUserState> {
on<UpdateGeneralAccess>(_onUpdateGeneralAccess); on<UpdateGeneralAccess>(_onUpdateGeneralAccess);
on<CopyLink>(_onCopyLink); on<CopyLink>(_onCopyLink);
on<SearchAvailableUsers>(_onSearchAvailableUsers); on<SearchAvailableUsers>(_onSearchAvailableUsers);
on<TurnIntoMember>(_onTurnIntoMember);
} }
final ShareWithUserRepository repository; final ShareWithUserRepository repository;
@ -48,14 +48,7 @@ class ShareWithUserBloc extends Bloc<ShareWithUserEvent, ShareWithUserState> {
viewId: pageId, viewId: pageId,
); );
final shareResult = await repository.getSharedUsersInPage( final users = await _getLatestSharedUsersOrCurrentUsers();
pageId: pageId,
);
final users = shareResult.fold(
(users) => users,
(error) => <SharedUser>[],
);
emit( emit(
state.copyWith( state.copyWith(
@ -74,6 +67,10 @@ class ShareWithUserBloc extends Bloc<ShareWithUserEvent, ShareWithUserState> {
state.copyWith( state.copyWith(
errorMessage: '', errorMessage: '',
initialResult: null, initialResult: null,
shareResult: null,
removeResult: null,
updateAccessLevelResult: null,
turnIntoMemberResult: null,
), ),
); );
@ -105,6 +102,9 @@ class ShareWithUserBloc extends Bloc<ShareWithUserEvent, ShareWithUserState> {
state.copyWith( state.copyWith(
errorMessage: '', errorMessage: '',
shareResult: null, shareResult: null,
turnIntoMemberResult: null,
removeResult: null,
updateAccessLevelResult: null,
), ),
); );
@ -114,19 +114,18 @@ class ShareWithUserBloc extends Bloc<ShareWithUserEvent, ShareWithUserState> {
emails: event.emails, emails: event.emails,
); );
result.fold( await result.fold(
(_) { (_) async {
final users = await _getLatestSharedUsersOrCurrentUsers();
emit( emit(
state.copyWith( state.copyWith(
shareResult: FlowySuccess(null), shareResult: FlowySuccess(null),
users: users,
), ),
); );
add(
const ShareWithUserEvent.getSharedUsers(),
);
}, },
(error) { (error) async {
emit( emit(
state.copyWith( state.copyWith(
errorMessage: error.msg, errorMessage: error.msg,
@ -143,7 +142,11 @@ class ShareWithUserBloc extends Bloc<ShareWithUserEvent, ShareWithUserState> {
) async { ) async {
emit( emit(
state.copyWith( state.copyWith(
errorMessage: '',
removeResult: null, removeResult: null,
shareResult: null,
updateAccessLevelResult: null,
turnIntoMemberResult: null,
), ),
); );
@ -152,19 +155,17 @@ class ShareWithUserBloc extends Bloc<ShareWithUserEvent, ShareWithUserState> {
emails: event.emails, emails: event.emails,
); );
result.fold( await result.fold(
(_) { (_) async {
final users = await _getLatestSharedUsersOrCurrentUsers();
emit( emit(
state.copyWith( state.copyWith(
removeResult: FlowySuccess(null), removeResult: FlowySuccess(null),
users: users,
), ),
); );
add(
const ShareWithUserEvent.getSharedUsers(),
);
}, },
(error) { (error) async {
emit( emit(
state.copyWith( state.copyWith(
isLoading: false, isLoading: false,
@ -191,24 +192,24 @@ class ShareWithUserBloc extends Bloc<ShareWithUserEvent, ShareWithUserState> {
emails: [event.email], emails: [event.email],
); );
result.fold( await result.fold(
(_) { (_) async {
final users = await _getLatestSharedUsersOrCurrentUsers();
emit( emit(
state.copyWith( state.copyWith(
updateAccessLevelResult: FlowySuccess(null), updateAccessLevelResult: FlowySuccess(null),
users: users,
), ),
); );
add(
const ShareWithUserEvent.getSharedUsers(),
);
}, },
(error) => emit( (error) async {
emit(
state.copyWith( state.copyWith(
errorMessage: error.msg, errorMessage: error.msg,
isLoading: false, isLoading: false,
), ),
), );
},
); );
} }
@ -271,6 +272,57 @@ class ShareWithUserBloc extends Bloc<ShareWithUserEvent, ShareWithUserState> {
), ),
); );
} }
Future<void> _onTurnIntoMember(
TurnIntoMember event,
Emitter<ShareWithUserState> emit,
) async {
emit(
state.copyWith(
turnIntoMemberResult: null,
errorMessage: '',
removeResult: null,
shareResult: null,
updateAccessLevelResult: null,
),
);
final result = await repository.changeRole(
pageId: pageId,
email: event.email,
role: ShareRole.member,
);
await result.fold(
(_) async {
final users = await _getLatestSharedUsersOrCurrentUsers();
emit(
state.copyWith(
turnIntoMemberResult: FlowySuccess(null),
users: users,
),
);
},
(error) async {
emit(
state.copyWith(
errorMessage: error.msg,
turnIntoMemberResult: FlowyFailure(error),
),
);
},
);
}
Future<SharedUsers> _getLatestSharedUsersOrCurrentUsers() async {
final shareResult = await repository.getSharedUsersInPage(
pageId: pageId,
);
return shareResult.fold(
(users) => users,
(error) => state.users,
);
}
} }
@freezed @freezed
@ -312,14 +364,19 @@ class ShareWithUserEvent with _$ShareWithUserEvent {
const factory ShareWithUserEvent.searchAvailableUsers({ const factory ShareWithUserEvent.searchAvailableUsers({
required String query, required String query,
}) = SearchAvailableUsers; }) = SearchAvailableUsers;
/// Turns the user into a member.
const factory ShareWithUserEvent.turnIntoMember({
required String email,
}) = TurnIntoMember;
} }
@freezed @freezed
class ShareWithUserState with _$ShareWithUserState { class ShareWithUserState with _$ShareWithUserState {
const factory ShareWithUserState({ const factory ShareWithUserState({
@Default(null) UserProfilePB? currentUser, @Default(null) UserProfilePB? currentUser,
@Default([]) List<SharedUser> users, @Default([]) SharedUsers users,
@Default([]) List<SharedUser> availableUsers, @Default([]) SharedUsers availableUsers,
@Default(false) bool isLoading, @Default(false) bool isLoading,
@Default('') String errorMessage, @Default('') String errorMessage,
@Default('') String shareLink, @Default('') String shareLink,
@ -329,6 +386,7 @@ class ShareWithUserState with _$ShareWithUserState {
@Default(null) FlowyResult<void, FlowyError>? shareResult, @Default(null) FlowyResult<void, FlowyError>? shareResult,
@Default(null) FlowyResult<void, FlowyError>? removeResult, @Default(null) FlowyResult<void, FlowyError>? removeResult,
@Default(null) FlowyResult<void, FlowyError>? updateAccessLevelResult, @Default(null) FlowyResult<void, FlowyError>? updateAccessLevelResult,
@Default(null) FlowyResult<void, FlowyError>? turnIntoMemberResult,
}) = _ShareWithUserState; }) = _ShareWithUserState;
factory ShareWithUserState.initial() => const ShareWithUserState(); factory ShareWithUserState.initial() => const ShareWithUserState();

View File

@ -9,7 +9,7 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
class ShareTab extends StatelessWidget { class ShareTab extends StatefulWidget {
const ShareTab({ const ShareTab({
super.key, super.key,
required this.workspaceId, required this.workspaceId,
@ -19,13 +19,27 @@ class ShareTab extends StatelessWidget {
final String workspaceId; final String workspaceId;
final String pageId; final String pageId;
@override
State<ShareTab> createState() => _ShareTabState();
}
class _ShareTabState extends State<ShareTab> {
final TextEditingController controller = TextEditingController();
@override
void dispose() {
controller.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context); final theme = AppFlowyTheme.of(context);
return BlocConsumer<ShareWithUserBloc, ShareWithUserState>( return BlocConsumer<ShareWithUserBloc, ShareWithUserState>(
listener: (context, state) { listener: (context, state) {
_onShareWithUserError(context, state); _onListenShareWithUserState(context, state);
}, },
builder: (context, state) { builder: (context, state) {
if (state.isLoading) { if (state.isLoading) {
@ -39,6 +53,7 @@ class ShareTab extends StatelessWidget {
// share page with user by email // share page with user by email
VSpace(theme.spacing.l), VSpace(theme.spacing.l),
ShareWithUserWidget( ShareWithUserWidget(
controller: controller,
onInvite: (emails) => _onSharePageWithUser( onInvite: (emails) => _onSharePageWithUser(
context, context,
emails: emails, emails: emails,
@ -92,7 +107,9 @@ class ShareTab extends StatelessWidget {
// do nothing. the event doesn't support in the backend yet // do nothing. the event doesn't support in the backend yet
}, },
onTurnIntoMember: (user) { onTurnIntoMember: (user) {
// do nothing. the event doesn't support in the backend yet context.read<ShareWithUserBloc>().add(
ShareWithUserEvent.turnIntoMember(email: user.email),
);
}, },
onRemoveAccess: (user) { onRemoveAccess: (user) {
context.read<ShareWithUserBloc>().add( context.read<ShareWithUserBloc>().add(
@ -102,13 +119,20 @@ class ShareTab extends StatelessWidget {
); );
} }
void _onShareWithUserError( void _onListenShareWithUserState(
BuildContext context, BuildContext context,
ShareWithUserState state, ShareWithUserState state,
) { ) {
final shareResult = state.shareResult; final shareResult = state.shareResult;
if (shareResult != null) { if (shareResult != null) {
shareResult.onFailure((error) { shareResult.fold((success) {
// clear the controller to avoid showing the previous emails
controller.clear();
showToastNotification(
message: 'Invitation sent',
);
}, (error) {
// TODO: handle the limiation error // TODO: handle the limiation error
showToastNotification( showToastNotification(
message: error.msg, message: error.msg,
@ -116,5 +140,33 @@ class ShareTab extends StatelessWidget {
); );
}); });
} }
final removeResult = state.removeResult;
if (removeResult != null) {
removeResult.fold((success) {
showToastNotification(
message: 'Removed guest successfully',
);
}, (error) {
showToastNotification(
message: error.msg,
type: ToastificationType.error,
);
});
}
final updateAccessLevelResult = state.updateAccessLevelResult;
if (updateAccessLevelResult != null) {
updateAccessLevelResult.fold((success) {
showToastNotification(
message: 'Updated access level successfully',
);
}, (error) {
showToastNotification(
message: error.msg,
type: ToastificationType.error,
);
});
}
} }
} }

View File

@ -40,7 +40,7 @@ class PeopleWithAccessSection extends StatelessWidget {
}); });
final String currentUserEmail; final String currentUserEmail;
final List<SharedUser> users; final SharedUsers users;
final PeopleWithAccessSectionCallbacks? callbacks; final PeopleWithAccessSectionCallbacks? callbacks;
@override @override

View File

@ -9,28 +9,33 @@ class ShareWithUserWidget extends StatefulWidget {
const ShareWithUserWidget({ const ShareWithUserWidget({
super.key, super.key,
required this.onInvite, required this.onInvite,
this.controller,
}); });
final void Function(List<String> emails) onInvite; final void Function(List<String> emails) onInvite;
final TextEditingController? controller;
@override @override
State<ShareWithUserWidget> createState() => _ShareWithUserWidgetState(); State<ShareWithUserWidget> createState() => _ShareWithUserWidgetState();
} }
class _ShareWithUserWidgetState extends State<ShareWithUserWidget> { class _ShareWithUserWidgetState extends State<ShareWithUserWidget> {
final TextEditingController controller = TextEditingController(); late final TextEditingController effectiveController;
bool isButtonEnabled = false; bool isButtonEnabled = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
controller.addListener(_onTextChanged); effectiveController = widget.controller ?? TextEditingController();
effectiveController.addListener(_onTextChanged);
} }
@override @override
void dispose() { void dispose() {
controller.dispose(); if (widget.controller == null) {
effectiveController.dispose();
}
super.dispose(); super.dispose();
} }
@ -43,7 +48,7 @@ class _ShareWithUserWidgetState extends State<ShareWithUserWidget> {
children: [ children: [
Expanded( Expanded(
child: AFTextField( child: AFTextField(
controller: controller, controller: effectiveController,
size: AFTextFieldSize.m, size: AFTextFieldSize.m,
hintText: LocaleKeys.shareTab_inviteByEmail.tr(), hintText: LocaleKeys.shareTab_inviteByEmail.tr(),
), ),
@ -53,7 +58,7 @@ class _ShareWithUserWidgetState extends State<ShareWithUserWidget> {
text: LocaleKeys.shareTab_invite.tr(), text: LocaleKeys.shareTab_invite.tr(),
disabled: !isButtonEnabled, disabled: !isButtonEnabled,
onTap: () { onTap: () {
widget.onInvite(controller.text.trim().split(',')); widget.onInvite(effectiveController.text.trim().split(','));
}, },
), ),
], ],
@ -62,7 +67,7 @@ class _ShareWithUserWidgetState extends State<ShareWithUserWidget> {
void _onTextChanged() { void _onTextChanged() {
setState(() { setState(() {
final texts = controller.text.trim().split(','); final texts = effectiveController.text.trim().split(',');
isButtonEnabled = texts.isNotEmpty && texts.every(isEmail); isButtonEnabled = texts.isNotEmpty && texts.every(isEmail);
}); });
} }

View File

@ -35,6 +35,11 @@ class SharedSection extends StatelessWidget {
return SharedSectionError(errorMessage: state.errorMessage); return SharedSectionError(errorMessage: state.errorMessage);
} }
// hide the shared section if there are no shared pages
if (state.sharedPages.isEmpty) {
return const SizedBox.shrink();
}
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [

View File

@ -22,7 +22,7 @@ extension SharedViewPBExtension on SharedViewPB {
} }
extension RepeatedSharedUserPBExtension on RepeatedSharedUserPB { extension RepeatedSharedUserPBExtension on RepeatedSharedUserPB {
List<SharedUser> get sharedUsers { SharedUsers get sharedUsers {
return items.map((e) => e.sharedUser).toList(); return items.map((e) => e.sharedUser).toList();
} }
} }

View File

@ -38,13 +38,15 @@ void main() {
wait: const Duration(milliseconds: 100), wait: const Duration(milliseconds: 100),
expect: () => [ expect: () => [
// First state: shareResult is null // First state: shareResult is null
isA<ShareWithUserState>()
.having((s) => s.shareResult, 'shareResult', isNull),
// Second state: shareResult is Success
isA<ShareWithUserState>()
.having((s) => s.shareResult, 'shareResult', isNotNull),
// Third state: users updated, shareResult still Success
isA<ShareWithUserState>().having( isA<ShareWithUserState>().having(
(s) => s.shareResult,
'shareResult',
isNull,
),
// Second state: shareResult is Success and users updated
isA<ShareWithUserState>()
.having((s) => s.shareResult, 'shareResult', isNotNull)
.having(
(s) => s.users.any((u) => u.email == email), (s) => s.users.any((u) => u.email == email),
'users contains new user', 'users contains new user',
isTrue, isTrue,
@ -65,11 +67,10 @@ void main() {
// First state: removeResult is null // First state: removeResult is null
isA<ShareWithUserState>() isA<ShareWithUserState>()
.having((s) => s.removeResult, 'removeResult', isNull), .having((s) => s.removeResult, 'removeResult', isNull),
// Second state: removeResult is Success // Second state: removeResult is Success and users updated
isA<ShareWithUserState>() isA<ShareWithUserState>()
.having((s) => s.removeResult, 'removeResult', isNotNull), .having((s) => s.removeResult, 'removeResult', isNotNull)
// Third state: users updated, removeResult still Success .having(
isA<ShareWithUserState>().having(
(s) => s.users.any((u) => u.email == email), (s) => s.users.any((u) => u.email == email),
'users contains removed user', 'users contains removed user',
isFalse, isFalse,
@ -94,19 +95,70 @@ void main() {
'updateAccessLevelResult', 'updateAccessLevelResult',
isNull, isNull,
), ),
// Second state: updateAccessLevelResult is Success // Second state: updateAccessLevelResult is Success and users updated
isA<ShareWithUserState>().having( isA<ShareWithUserState>()
.having(
(s) => s.updateAccessLevelResult, (s) => s.updateAccessLevelResult,
'updateAccessLevelResult', 'updateAccessLevelResult',
isNotNull, isNotNull,
), )
// Third state: users updated, vivian's access level is fullAccess .having(
isA<ShareWithUserState>().having(
(s) => s.users.firstWhere((u) => u.email == email).accessLevel, (s) => s.users.firstWhere((u) => u.email == email).accessLevel,
'vivian accessLevel', 'vivian accessLevel',
ShareAccessLevel.fullAccess, ShareAccessLevel.fullAccess,
), ),
], ],
); );
final guestEmail = 'guest@appflowy.io';
blocTest<ShareWithUserBloc, ShareWithUserState>(
'turns user into member',
build: () => bloc,
act: (bloc) => bloc
..add(
ShareWithUserEvent.share(
emails: [guestEmail],
accessLevel: ShareAccessLevel.readOnly,
),
)
..add(
ShareWithUserEvent.turnIntoMember(
email: guestEmail,
),
),
wait: const Duration(milliseconds: 100),
expect: () => [
// First state: shareResult is null
isA<ShareWithUserState>().having(
(s) => s.shareResult,
'shareResult',
isNull,
),
// Second state: shareResult is Success and users updated
isA<ShareWithUserState>()
.having(
(s) => s.shareResult,
'shareResult',
isNotNull,
)
.having(
(s) => s.users.any((u) => u.email == guestEmail),
'users contains guest@appflowy.io',
isTrue,
),
// Third state: turnIntoMemberResult is Success and users updated
isA<ShareWithUserState>()
.having(
(s) => s.turnIntoMemberResult,
'turnIntoMemberResult',
isNotNull,
)
.having(
(s) => s.users.firstWhere((u) => u.email == guestEmail).role,
'guest@appflowy.io role',
ShareRole.member,
),
],
);
}); });
} }