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_role.dart';
typedef SharedUsers = List<SharedUser>;
/// Represents a user with a role on a shared page.
class SharedUser {
SharedUser({

View File

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

View File

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

View File

@ -8,12 +8,12 @@ import 'package:appflowy_result/appflowy_result.dart';
/// for the future.
abstract class ShareWithUserRepository {
/// 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,
});
/// 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,
});

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/shared_user.dart';
import 'package:appflowy/features/share_tab/data/models/models.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/shared/share/constants.dart';
@ -27,6 +26,7 @@ class ShareWithUserBloc extends Bloc<ShareWithUserEvent, ShareWithUserState> {
on<UpdateGeneralAccess>(_onUpdateGeneralAccess);
on<CopyLink>(_onCopyLink);
on<SearchAvailableUsers>(_onSearchAvailableUsers);
on<TurnIntoMember>(_onTurnIntoMember);
}
final ShareWithUserRepository repository;
@ -48,14 +48,7 @@ class ShareWithUserBloc extends Bloc<ShareWithUserEvent, ShareWithUserState> {
viewId: pageId,
);
final shareResult = await repository.getSharedUsersInPage(
pageId: pageId,
);
final users = shareResult.fold(
(users) => users,
(error) => <SharedUser>[],
);
final users = await _getLatestSharedUsersOrCurrentUsers();
emit(
state.copyWith(
@ -74,6 +67,10 @@ class ShareWithUserBloc extends Bloc<ShareWithUserEvent, ShareWithUserState> {
state.copyWith(
errorMessage: '',
initialResult: null,
shareResult: null,
removeResult: null,
updateAccessLevelResult: null,
turnIntoMemberResult: null,
),
);
@ -105,6 +102,9 @@ class ShareWithUserBloc extends Bloc<ShareWithUserEvent, ShareWithUserState> {
state.copyWith(
errorMessage: '',
shareResult: null,
turnIntoMemberResult: null,
removeResult: null,
updateAccessLevelResult: null,
),
);
@ -114,19 +114,18 @@ class ShareWithUserBloc extends Bloc<ShareWithUserEvent, ShareWithUserState> {
emails: event.emails,
);
result.fold(
(_) {
await result.fold(
(_) async {
final users = await _getLatestSharedUsersOrCurrentUsers();
emit(
state.copyWith(
shareResult: FlowySuccess(null),
users: users,
),
);
add(
const ShareWithUserEvent.getSharedUsers(),
);
},
(error) {
(error) async {
emit(
state.copyWith(
errorMessage: error.msg,
@ -143,7 +142,11 @@ class ShareWithUserBloc extends Bloc<ShareWithUserEvent, ShareWithUserState> {
) async {
emit(
state.copyWith(
errorMessage: '',
removeResult: null,
shareResult: null,
updateAccessLevelResult: null,
turnIntoMemberResult: null,
),
);
@ -152,19 +155,17 @@ class ShareWithUserBloc extends Bloc<ShareWithUserEvent, ShareWithUserState> {
emails: event.emails,
);
result.fold(
(_) {
await result.fold(
(_) async {
final users = await _getLatestSharedUsersOrCurrentUsers();
emit(
state.copyWith(
removeResult: FlowySuccess(null),
users: users,
),
);
add(
const ShareWithUserEvent.getSharedUsers(),
);
},
(error) {
(error) async {
emit(
state.copyWith(
isLoading: false,
@ -191,24 +192,24 @@ class ShareWithUserBloc extends Bloc<ShareWithUserEvent, ShareWithUserState> {
emails: [event.email],
);
result.fold(
(_) {
await result.fold(
(_) async {
final users = await _getLatestSharedUsersOrCurrentUsers();
emit(
state.copyWith(
updateAccessLevelResult: FlowySuccess(null),
users: users,
),
);
add(
const ShareWithUserEvent.getSharedUsers(),
},
(error) async {
emit(
state.copyWith(
errorMessage: error.msg,
isLoading: false,
),
);
},
(error) => emit(
state.copyWith(
errorMessage: error.msg,
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
@ -312,14 +364,19 @@ class ShareWithUserEvent with _$ShareWithUserEvent {
const factory ShareWithUserEvent.searchAvailableUsers({
required String query,
}) = SearchAvailableUsers;
/// Turns the user into a member.
const factory ShareWithUserEvent.turnIntoMember({
required String email,
}) = TurnIntoMember;
}
@freezed
class ShareWithUserState with _$ShareWithUserState {
const factory ShareWithUserState({
@Default(null) UserProfilePB? currentUser,
@Default([]) List<SharedUser> users,
@Default([]) List<SharedUser> availableUsers,
@Default([]) SharedUsers users,
@Default([]) SharedUsers availableUsers,
@Default(false) bool isLoading,
@Default('') String errorMessage,
@Default('') String shareLink,
@ -329,6 +386,7 @@ class ShareWithUserState with _$ShareWithUserState {
@Default(null) FlowyResult<void, FlowyError>? shareResult,
@Default(null) FlowyResult<void, FlowyError>? removeResult,
@Default(null) FlowyResult<void, FlowyError>? updateAccessLevelResult,
@Default(null) FlowyResult<void, FlowyError>? turnIntoMemberResult,
}) = _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_bloc/flutter_bloc.dart';
class ShareTab extends StatelessWidget {
class ShareTab extends StatefulWidget {
const ShareTab({
super.key,
required this.workspaceId,
@ -19,13 +19,27 @@ class ShareTab extends StatelessWidget {
final String workspaceId;
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
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return BlocConsumer<ShareWithUserBloc, ShareWithUserState>(
listener: (context, state) {
_onShareWithUserError(context, state);
_onListenShareWithUserState(context, state);
},
builder: (context, state) {
if (state.isLoading) {
@ -39,6 +53,7 @@ class ShareTab extends StatelessWidget {
// share page with user by email
VSpace(theme.spacing.l),
ShareWithUserWidget(
controller: controller,
onInvite: (emails) => _onSharePageWithUser(
context,
emails: emails,
@ -92,7 +107,9 @@ class ShareTab extends StatelessWidget {
// do nothing. the event doesn't support in the backend yet
},
onTurnIntoMember: (user) {
// do nothing. the event doesn't support in the backend yet
context.read<ShareWithUserBloc>().add(
ShareWithUserEvent.turnIntoMember(email: user.email),
);
},
onRemoveAccess: (user) {
context.read<ShareWithUserBloc>().add(
@ -102,13 +119,20 @@ class ShareTab extends StatelessWidget {
);
}
void _onShareWithUserError(
void _onListenShareWithUserState(
BuildContext context,
ShareWithUserState state,
) {
final shareResult = state.shareResult;
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
showToastNotification(
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 List<SharedUser> users;
final SharedUsers users;
final PeopleWithAccessSectionCallbacks? callbacks;
@override

View File

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

View File

@ -35,6 +35,11 @@ class SharedSection extends StatelessWidget {
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(
crossAxisAlignment: CrossAxisAlignment.start,
children: [

View File

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

View File

@ -38,17 +38,19 @@ void main() {
wait: const Duration(milliseconds: 100),
expect: () => [
// 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(
(s) => s.users.any((u) => u.email == email),
'users contains new user',
isTrue,
(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),
'users contains new user',
isTrue,
),
],
);
@ -65,15 +67,14 @@ void main() {
// First state: removeResult is null
isA<ShareWithUserState>()
.having((s) => s.removeResult, 'removeResult', isNull),
// Second state: removeResult is Success
// Second state: removeResult is Success and users updated
isA<ShareWithUserState>()
.having((s) => s.removeResult, 'removeResult', isNotNull),
// Third state: users updated, removeResult still Success
isA<ShareWithUserState>().having(
(s) => s.users.any((u) => u.email == email),
'users contains removed user',
isFalse,
),
.having((s) => s.removeResult, 'removeResult', isNotNull)
.having(
(s) => s.users.any((u) => u.email == email),
'users contains removed user',
isFalse,
),
],
);
@ -94,18 +95,69 @@ void main() {
'updateAccessLevelResult',
isNull,
),
// Second state: updateAccessLevelResult is Success
isA<ShareWithUserState>().having(
(s) => s.updateAccessLevelResult,
'updateAccessLevelResult',
isNotNull,
// Second state: updateAccessLevelResult is Success and users updated
isA<ShareWithUserState>()
.having(
(s) => s.updateAccessLevelResult,
'updateAccessLevelResult',
isNotNull,
)
.having(
(s) => s.users.firstWhere((u) => u.email == email).accessLevel,
'vivian accessLevel',
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,
),
),
// Third state: users updated, vivian's access level is fullAccess
wait: const Duration(milliseconds: 100),
expect: () => [
// First state: shareResult is null
isA<ShareWithUserState>().having(
(s) => s.users.firstWhere((u) => u.email == email).accessLevel,
'vivian accessLevel',
ShareAccessLevel.fullAccess,
(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,
),
],
);
});