chore: merge branch 'upstream/main' into feat/billing-client

This commit is contained in:
Mathias Mogensen
2024-06-10 12:01:29 +02:00
201 changed files with 5987 additions and 2399 deletions

View File

@ -1,28 +0,0 @@
name: Build Bot
on:
issue_comment:
types: [created]
jobs:
dispatch_slash_command:
runs-on: ubuntu-latest
steps:
- name: Checkout source code
uses: actions/checkout@v4
# get build name from pubspec.yaml
- name: Get build version
working-directory: frontend/appflowy_flutter
id: get_build_name
run: |
echo "fetching version from pubspec.yaml..."
echo "build_name=$(grep 'version: ' pubspec.yaml | awk '{print $2}')" >> $GITHUB_OUTPUT
- uses: peter-evans/slash-command-dispatch@v4
with:
token: ${{ secrets.PAT }}
commands: build
static-args: |
ref=refs/pull/${{ github.event.issue.number }}/head
build_name=${{ steps.get_build_name.outputs.build_name }}

View File

@ -62,7 +62,7 @@ jobs:
with:
SSH_PRIVATE_KEY: ${{ env.SSH_PRIVATE_KEY }}
ARGS: "-rlgoDzvc -i"
SOURCE: "frontend/appflowy_web_app/dist frontend/appflowy_web_app/Dockerfile frontend/appflowy_web_app/nginx.conf frontend/appflowy_web_app/.env nginx-signed.crt nginx-signed.key"
SOURCE: "frontend/appflowy_web_app/dist frontend/appflowy_web_app/server.cjs frontend/appflowy_web_app/start.sh frontend/appflowy_web_app/Dockerfile frontend/appflowy_web_app/nginx.conf frontend/appflowy_web_app/.env nginx-signed.crt nginx-signed.key"
REMOTE_HOST: ${{ env.REMOTE_HOST }}
REMOTE_USER: ${{ env.REMOTE_USER }}
EXCLUDE: "frontend/appflowy_web_app/dist/, frontend/appflowy_web_app/node_modules/"

View File

@ -6,6 +6,7 @@ on:
- ".github/workflows/web2_ci.yaml"
- "frontend/appflowy_web_app/**"
- "frontend/resources/**"
env:
NODE_VERSION: "18.16.0"
PNPM_VERSION: "8.5.0"
@ -52,8 +53,13 @@ jobs:
run: |
pnpm run test:unit
- name: Generate and post coverage summary
working-directory: frontend/appflowy_web_app
run: |
pnpm run merge-coverage
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v2
with:
token: cf9245e0-e136-4e21-b0ee-35755fa0c493
files: frontend/appflowy_web_app/coverage/jest/lcov.info,frontend/appflowy_web_app/coverage/cypress/lcov.info
flags: appflowy_web_app
name: frontend/appflowy_web_app
fail_ci_if_error: true
verbose: true

View File

@ -6,7 +6,6 @@ import 'package:appflowy/mobile/presentation/base/view_page/app_bar_buttons.dart
import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_state_container.dart';
import 'package:appflowy/plugins/base/emoji/emoji_text.dart';
import 'package:appflowy/plugins/document/presentation/document_collaborators.dart';
import 'package:appflowy/plugins/shared/sync_indicator.dart';
import 'package:appflowy/shared/feature_flags.dart';
import 'package:appflowy/startup/plugin/plugin.dart';
import 'package:appflowy/startup/startup.dart';
@ -195,13 +194,6 @@ class _MobileViewPageState extends State<MobileViewPage> {
padding: const EdgeInsets.symmetric(vertical: 8),
view: view,
),
const HSpace(16.0),
DocumentSyncIndicator(view: view),
const HSpace(12.0),
]);
} else {
actions.addAll([
DatabaseSyncIndicator(view: view),
const HSpace(12.0),
]);
}

View File

@ -23,7 +23,7 @@ class AddNewPageWidgetBottomSheet extends StatelessWidget {
text: LocaleKeys.document_menuName.tr(),
height: 52.0,
leftIcon: const FlowySvg(
FlowySvgs.document_s,
FlowySvgs.icon_document_s,
size: Size.square(18),
),
showTopBorder: false,
@ -33,7 +33,7 @@ class AddNewPageWidgetBottomSheet extends StatelessWidget {
text: LocaleKeys.grid_menuName.tr(),
height: 52.0,
leftIcon: const FlowySvg(
FlowySvgs.grid_s,
FlowySvgs.icon_grid_s,
size: Size.square(18),
),
showTopBorder: false,
@ -43,7 +43,7 @@ class AddNewPageWidgetBottomSheet extends StatelessWidget {
text: LocaleKeys.board_menuName.tr(),
height: 52.0,
leftIcon: const FlowySvg(
FlowySvgs.board_s,
FlowySvgs.icon_board_s,
size: Size.square(18),
),
showTopBorder: false,
@ -53,7 +53,7 @@ class AddNewPageWidgetBottomSheet extends StatelessWidget {
text: LocaleKeys.calendar_menuName.tr(),
height: 52.0,
leftIcon: const FlowySvg(
FlowySvgs.calendar_s,
FlowySvgs.icon_calendar_s,
size: Size.square(18),
),
showTopBorder: false,

View File

@ -89,7 +89,7 @@ class MobileViewBottomSheetBody extends StatelessWidget {
children: [
MobileQuickActionButton(
text: LocaleKeys.button_rename.tr(),
icon: FlowySvgs.m_rename_s,
icon: FlowySvgs.view_item_rename_s,
onTap: () => onAction(
MobileViewBottomSheetBodyAction.rename,
),
@ -99,10 +99,7 @@ class MobileViewBottomSheetBody extends StatelessWidget {
text: isFavorite
? LocaleKeys.button_removeFromFavorites.tr()
: LocaleKeys.button_addToFavorites.tr(),
icon: isFavorite
? FlowySvgs.m_favorite_selected_lg
: FlowySvgs.m_favorite_unselected_lg,
iconColor: isFavorite ? Colors.yellow : null,
icon: isFavorite ? FlowySvgs.unfavorite_s : FlowySvgs.favorite_s,
onTap: () => onAction(
isFavorite
? MobileViewBottomSheetBodyAction.removeFromFavorites
@ -112,7 +109,7 @@ class MobileViewBottomSheetBody extends StatelessWidget {
_divider(),
MobileQuickActionButton(
text: LocaleKeys.button_duplicate.tr(),
icon: FlowySvgs.m_duplicate_s,
icon: FlowySvgs.duplicate_s,
onTap: () => onAction(
MobileViewBottomSheetBodyAction.duplicate,
),
@ -121,7 +118,7 @@ class MobileViewBottomSheetBody extends StatelessWidget {
MobileQuickActionButton(
text: LocaleKeys.button_delete.tr(),
textColor: Theme.of(context).colorScheme.error,
icon: FlowySvgs.m_delete_s,
icon: FlowySvgs.trash_s,
iconColor: Theme.of(context).colorScheme.error,
onTap: () => onAction(
MobileViewBottomSheetBodyAction.delete,

View File

@ -74,13 +74,14 @@ enum MobilePaneActionType {
return AddNewPageWidgetBottomSheet(
view: view,
onAction: (layout) {
context.read<ViewBloc>().add(
ViewEvent.createView(
LocaleKeys.menuAppHeader_defaultNewPageName.tr(),
layout,
section: spaceType!.toViewSectionPB,
),
);
Navigator.of(sheetContext).pop();
viewBloc.add(
ViewEvent.createView(
LocaleKeys.menuAppHeader_defaultNewPageName.tr(),
layout,
section: spaceType!.toViewSectionPB,
),
);
},
);
},

View File

@ -140,7 +140,7 @@ class _MobileRowDetailPageState extends State<MobileRowDetailPage> {
MobileQuickActionButton(
onTap: () =>
_performAction(viewId, _bloc.state.currentRowId, false),
icon: FlowySvgs.copy_s,
icon: FlowySvgs.duplicate_s,
text: LocaleKeys.button_duplicate.tr(),
),
const Divider(height: 8.5, thickness: 0.5),
@ -148,7 +148,7 @@ class _MobileRowDetailPageState extends State<MobileRowDetailPage> {
onTap: () => _performAction(viewId, _bloc.state.currentRowId, true),
text: LocaleKeys.button_delete.tr(),
textColor: Theme.of(context).colorScheme.error,
icon: FlowySvgs.m_delete_m,
icon: FlowySvgs.trash_s,
iconColor: Theme.of(context).colorScheme.error,
),
const Divider(height: 8.5, thickness: 0.5),

View File

@ -5,8 +5,8 @@ import 'package:appflowy/mobile/presentation/database/field/mobile_field_bottom_
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
import 'package:appflowy/plugins/database/application/field/field_controller.dart';
import 'package:appflowy/plugins/database/application/field/field_editor_bloc.dart';
import 'package:appflowy/plugins/database/domain/field_backend_service.dart';
import 'package:appflowy/plugins/database/application/field/field_info.dart';
import 'package:appflowy/plugins/database/domain/field_backend_service.dart';
import 'package:appflowy/plugins/database/widgets/setting/field_visibility_extension.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';

View File

@ -107,9 +107,9 @@ enum _Action {
FlowySvgData get icon {
return switch (this) {
edit => FlowySvgs.edit_s,
duplicate => FlowySvgs.copy_s,
delete => FlowySvgs.delete_s,
edit => FlowySvgs.view_item_rename_s,
duplicate => FlowySvgs.duplicate_s,
delete => FlowySvgs.trash_s,
};
}

View File

@ -298,9 +298,12 @@ class _SingleMobileInnerViewItemState extends State<SingleMobileInnerViewItem> {
widget.view.icon.value,
fontSize: 20.0,
)
: SizedBox.square(
dimension: 18.0,
child: widget.view.defaultIcon(),
: Opacity(
opacity: 0.7,
child: SizedBox.square(
dimension: 18.0,
child: widget.view.defaultIcon(),
),
);
return icon;
}

View File

@ -1,42 +1,131 @@
import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'dart:async';
import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-chat/entities.pb.dart';
import 'package:fixnum/fixnum.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_types/flutter_chat_types.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'chat_ai_message_bloc.freezed.dart';
class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
ChatAIMessageBloc({
required Message message,
dynamic message,
required this.chatId,
required this.questionId,
}) : super(ChatAIMessageState.initial(message)) {
if (state.stream != null) {
_subscription = state.stream!.listen((text) {
if (isClosed) {
return;
}
if (text.startsWith("data:")) {
add(ChatAIMessageEvent.newText(text.substring(5)));
} else if (text.startsWith("error:")) {
add(ChatAIMessageEvent.receiveError(text.substring(5)));
}
});
if (state.stream!.error != null) {
Future.delayed(const Duration(milliseconds: 300), () {
if (!isClosed) {
add(ChatAIMessageEvent.receiveError(state.stream!.error!));
}
});
}
}
on<ChatAIMessageEvent>(
(event, emit) async {
await event.when(
initial: () async {},
update: (userProfile, deviceId, states) {},
newText: (newText) {
emit(state.copyWith(text: state.text + newText, error: null));
},
receiveError: (error) {
emit(state.copyWith(error: error));
},
retry: () {
if (questionId is! Int64) {
Log.error("Question id is not Int64: $questionId");
return;
}
emit(
state.copyWith(
retryState: const LoadingState.loading(),
error: null,
),
);
final payload = ChatMessageIdPB(
chatId: chatId,
messageId: questionId,
);
ChatEventGetAnswerForQuestion(payload).send().then((result) {
if (!isClosed) {
result.fold(
(answer) {
add(ChatAIMessageEvent.retryResult(answer.content));
},
(err) {
Log.error("Failed to get answer: $err");
add(ChatAIMessageEvent.receiveError(err.toString()));
},
);
}
});
},
retryResult: (String text) {
emit(
state.copyWith(
text: text,
error: null,
retryState: const LoadingState.finish(),
),
);
},
);
},
);
}
@override
Future<void> close() {
_subscription?.cancel();
return super.close();
}
StreamSubscription<AnswerStreamElement>? _subscription;
final String chatId;
final Int64? questionId;
}
@freezed
class ChatAIMessageEvent with _$ChatAIMessageEvent {
const factory ChatAIMessageEvent.initial() = Initial;
const factory ChatAIMessageEvent.update(
UserProfilePB userProfile,
String deviceId,
DocumentAwarenessStatesPB states,
) = Update;
const factory ChatAIMessageEvent.newText(String text) = _NewText;
const factory ChatAIMessageEvent.receiveError(String error) = _ReceiveError;
const factory ChatAIMessageEvent.retry() = _Retry;
const factory ChatAIMessageEvent.retryResult(String text) = _RetryResult;
}
@freezed
class ChatAIMessageState with _$ChatAIMessageState {
const factory ChatAIMessageState({
required Message message,
AnswerStream? stream,
String? error,
required String text,
required LoadingState retryState,
}) = _ChatAIMessageState;
factory ChatAIMessageState.initial(Message message) =>
ChatAIMessageState(message: message);
factory ChatAIMessageState.initial(dynamic text) {
return ChatAIMessageState(
text: text is String ? text : "",
stream: text is AnswerStream ? text : null,
retryState: const LoadingState.finish(),
);
}
}

View File

@ -1,6 +1,12 @@
import 'dart:async';
import 'dart:collection';
import 'dart:ffi';
import 'dart:isolate';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-chat/entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-error/code.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
@ -11,10 +17,8 @@ import 'package:flutter_chat_types/flutter_chat_types.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:nanoid/nanoid.dart';
import 'chat_message_listener.dart';
part 'chat_bloc.freezed.dart';
const canRetryKey = "canRetry";
const sendMessageErrorKey = "sendMessageError";
class ChatBloc extends Bloc<ChatEvent, ChatState> {
@ -26,78 +30,31 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
super(
ChatState.initial(view, userProfile),
) {
_startListening();
_dispatch();
listener.start(
chatMessageCallback: _handleChatMessage,
lastUserSentMessageCallback: (message) {
if (!isClosed) {
add(ChatEvent.didSentUserMessage(message));
}
},
chatErrorMessageCallback: (err) {
if (!isClosed) {
Log.error("chat error: ${err.errorMessage}");
final metadata = OnetimeShotType.serverStreamError.toMap();
if (state.lastSentMessage != null) {
metadata[canRetryKey] = "true";
}
final error = CustomMessage(
metadata: metadata,
author: const User(id: "system"),
id: 'system',
);
add(ChatEvent.streaming([error]));
add(const ChatEvent.didFinishStreaming());
}
},
latestMessageCallback: (list) {
if (!isClosed) {
final messages = list.messages.map(_createChatMessage).toList();
add(ChatEvent.didLoadLatestMessages(messages));
}
},
prevMessageCallback: (list) {
if (!isClosed) {
final messages = list.messages.map(_createChatMessage).toList();
add(ChatEvent.didLoadPreviousMessages(messages, list.hasMore));
}
},
finishAnswerQuestionCallback: () {
if (!isClosed) {
add(const ChatEvent.didFinishStreaming());
if (state.lastSentMessage != null) {
final payload = ChatMessageIdPB(
chatId: chatId,
messageId: state.lastSentMessage!.messageId,
);
// When user message was sent to the server, we start gettting related question
ChatEventGetRelatedQuestion(payload).send().then((result) {
if (!isClosed) {
result.fold(
(list) {
add(
ChatEvent.didReceiveRelatedQuestion(list.items),
);
},
(err) {
Log.error("Failed to get related question: $err");
},
);
}
});
}
}
},
);
}
final ChatMessageListener listener;
final String chatId;
/// The last streaming message id
String lastStreamMessageId = '';
/// Using a temporary map to associate the real message ID with the last streaming message ID.
///
/// When a message is streaming, it does not have a real message ID. To maintain the relationship
/// between the real message ID and the last streaming message ID, we use this map to store the associations.
///
/// This map will be updated when receiving a message from the server and its author type
/// is 3 (AI response).
final HashMap<String, String> temporaryMessageIDMap = HashMap();
@override
Future<void> close() {
listener.stop();
Future<void> close() async {
if (state.answerStream != null) {
await state.answerStream?.dispose();
}
await listener.stop();
return super.close();
}
@ -114,8 +71,9 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
},
startLoadingPrevMessage: () async {
Int64? beforeMessageId;
if (state.messages.isNotEmpty) {
beforeMessageId = Int64.parseInt(state.messages.last.id);
final oldestMessage = _getOlderstMessage();
if (oldestMessage != null) {
beforeMessageId = Int64.parseInt(oldestMessage.id);
}
_loadPrevMessage(beforeMessageId);
emit(
@ -126,8 +84,13 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
},
didLoadPreviousMessages: (List<Message> messages, bool hasMore) {
Log.debug("did load previous messages: ${messages.length}");
final uniqueMessages = {...state.messages, ...messages}.toList()
final onetimeMessages = _getOnetimeMessages();
final allMessages = _perminentMessages();
final uniqueMessages = {...allMessages, ...messages}.toList()
..sort((a, b) => b.id.compareTo(a.id));
uniqueMessages.insertAll(0, onetimeMessages);
emit(
state.copyWith(
messages: uniqueMessages,
@ -137,8 +100,12 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
);
},
didLoadLatestMessages: (List<Message> messages) {
final uniqueMessages = {...state.messages, ...messages}.toList()
final onetimeMessages = _getOnetimeMessages();
final allMessages = _perminentMessages();
final uniqueMessages = {...allMessages, ...messages}.toList()
..sort((a, b) => b.id.compareTo(a.id));
uniqueMessages.insertAll(0, onetimeMessages);
emit(
state.copyWith(
messages: uniqueMessages,
@ -146,55 +113,43 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
),
);
},
streaming: (List<Message> messages) {
streaming: (Message message) {
final allMessages = _perminentMessages();
allMessages.insertAll(0, messages);
emit(state.copyWith(messages: allMessages));
},
didFinishStreaming: () {
allMessages.insert(0, message);
emit(
state.copyWith(
answerQuestionStatus: const LoadingState.finish(),
messages: allMessages,
streamingStatus: const LoadingState.loading(),
),
);
},
sendMessage: (String message) async {
await _handleSentMessage(message, emit);
// Create a loading indicator
final loadingMessage =
_loadingMessage(state.userProfile.id.toString());
final allMessages = List<Message>.from(state.messages)
..insert(0, loadingMessage);
didFinishStreaming: () {
emit(
state.copyWith(streamingStatus: const LoadingState.finish()),
);
},
receveMessage: (Message message) {
final allMessages = _perminentMessages();
// remove message with the same id
allMessages.removeWhere((element) => element.id == message.id);
allMessages.insert(0, message);
emit(
state.copyWith(
messages: allMessages,
),
);
},
sendMessage: (String message) {
_startStreamingMessage(message, emit);
final allMessages = _perminentMessages();
emit(
state.copyWith(
lastSentMessage: null,
messages: allMessages,
answerQuestionStatus: const LoadingState.loading(),
relatedQuestions: [],
),
);
},
retryGenerate: () {
if (state.lastSentMessage == null) {
return;
}
final payload = ChatMessageIdPB(
chatId: chatId,
messageId: state.lastSentMessage!.messageId,
);
ChatEventGetAnswerForQuestion(payload).send().then((result) {
if (!isClosed) {
result.fold(
(answer) => _handleChatMessage(answer),
(err) {
Log.error("Failed to get answer: $err");
},
);
}
});
},
didReceiveRelatedQuestion: (List<RelatedQuestionPB> questions) {
final allMessages = _perminentMessages();
final message = CustomMessage(
@ -224,11 +179,104 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
),
);
},
didUpdateAnswerStream: (AnswerStream stream) {
emit(state.copyWith(answerStream: stream));
},
stopStream: () async {
if (state.answerStream == null) {
return;
}
final payload = StopStreamPB(chatId: chatId);
await ChatEventStopStream(payload).send();
final allMessages = _perminentMessages();
if (state.streamingStatus != const LoadingState.finish()) {
// If the streaming is not started, remove the message from the list
if (!state.answerStream!.hasStarted) {
allMessages.removeWhere(
(element) => element.id == lastStreamMessageId,
);
lastStreamMessageId = "";
}
// when stop stream, we will set the answer stream to null. Which means the streaming
// is finished or canceled.
emit(
state.copyWith(
messages: allMessages,
answerStream: null,
streamingStatus: const LoadingState.finish(),
),
);
}
},
);
},
);
}
void _startListening() {
listener.start(
chatMessageCallback: (pb) {
if (!isClosed) {
// 3 mean message response from AI
if (pb.authorType == 3 && lastStreamMessageId.isNotEmpty) {
temporaryMessageIDMap[pb.messageId.toString()] =
lastStreamMessageId;
lastStreamMessageId = "";
}
final message = _createTextMessage(pb);
add(ChatEvent.receveMessage(message));
}
},
chatErrorMessageCallback: (err) {
if (!isClosed) {
Log.error("chat error: ${err.errorMessage}");
add(const ChatEvent.didFinishStreaming());
}
},
latestMessageCallback: (list) {
if (!isClosed) {
final messages = list.messages.map(_createTextMessage).toList();
add(ChatEvent.didLoadLatestMessages(messages));
}
},
prevMessageCallback: (list) {
if (!isClosed) {
final messages = list.messages.map(_createTextMessage).toList();
add(ChatEvent.didLoadPreviousMessages(messages, list.hasMore));
}
},
finishStreamingCallback: () {
if (!isClosed) {
add(const ChatEvent.didFinishStreaming());
// The answer strema will bet set to null after the streaming is finished or canceled.
// so if the answer stream is null, we will not get related question.
if (state.lastSentMessage != null && state.answerStream != null) {
final payload = ChatMessageIdPB(
chatId: chatId,
messageId: state.lastSentMessage!.messageId,
);
// When user message was sent to the server, we start gettting related question
ChatEventGetRelatedQuestion(payload).send().then((result) {
if (!isClosed) {
result.fold(
(list) {
add(ChatEvent.didReceiveRelatedQuestion(list.items));
},
(err) {
Log.error("Failed to get related question: $err");
},
);
}
});
}
}
},
);
}
// Returns the list of messages that are not include one-time messages.
List<Message> _perminentMessages() {
final allMessages = state.messages.where((element) {
@ -238,6 +286,22 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
return allMessages;
}
List<Message> _getOnetimeMessages() {
final messages = state.messages.where((element) {
return (element.metadata?.containsKey(onetimeShotType) == true);
}).toList();
return messages;
}
Message? _getOlderstMessage() {
// get the last message that is not a one-time message
final message = state.messages.lastWhereOrNull((element) {
return !(element.metadata?.containsKey(onetimeShotType) == true);
});
return message;
}
void _loadPrevMessage(Int64? beforeMessageId) {
final payload = LoadPrevChatMessagePB(
chatId: state.view.id,
@ -247,68 +311,91 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
ChatEventLoadPrevMessage(payload).send();
}
Future<void> _handleSentMessage(
Future<void> _startStreamingMessage(
String message,
Emitter<ChatState> emit,
) async {
final payload = SendChatPayloadPB(
if (state.answerStream != null) {
await state.answerStream?.dispose();
}
final answerStream = AnswerStream();
add(ChatEvent.didUpdateAnswerStream(answerStream));
final payload = StreamChatPayloadPB(
chatId: state.view.id,
message: message,
messageType: ChatMessageTypePB.User,
textStreamPort: Int64(answerStream.nativePort),
);
final result = await ChatEventSendMessage(payload).send();
// Stream message to the server
final result = await ChatEventStreamMessage(payload).send();
result.fold(
(_) {},
(ChatMessagePB question) {
if (!isClosed) {
add(ChatEvent.didSentUserMessage(question));
final questionMessageId = question.messageId;
final message = _createTextMessage(question);
add(ChatEvent.receveMessage(message));
final streamAnswer =
_createStreamMessage(answerStream, questionMessageId);
add(ChatEvent.streaming(streamAnswer));
}
},
(err) {
if (!isClosed) {
Log.error("Failed to send message: ${err.msg}");
final metadata = OnetimeShotType.invalidSendMesssage.toMap();
metadata[sendMessageErrorKey] = err.msg;
if (err.code != ErrorCode.Internal) {
metadata[sendMessageErrorKey] = err.msg;
}
final error = CustomMessage(
metadata: metadata,
author: const User(id: "system"),
id: 'system',
);
add(ChatEvent.streaming([error]));
add(ChatEvent.receveMessage(error));
}
},
);
}
void _handleChatMessage(ChatMessagePB pb) {
if (!isClosed) {
final message = _createChatMessage(pb);
final messages = pb.hasFollowing
? [_loadingMessage(0.toString()), message]
: [message];
add(ChatEvent.streaming(messages));
}
}
Message _createStreamMessage(AnswerStream stream, Int64 questionMessageId) {
final streamMessageId = nanoid();
lastStreamMessageId = streamMessageId;
Message _loadingMessage(String id) {
return CustomMessage(
author: User(id: id),
metadata: OnetimeShotType.loading.toMap(),
// fake id
id: nanoid(),
return TextMessage(
author: User(id: nanoid()),
metadata: {
"$AnswerStream": stream,
"question": questionMessageId,
"chatId": chatId,
},
id: streamMessageId,
text: '',
);
}
Message _createChatMessage(ChatMessagePB message) {
final messageId = message.messageId.toString();
Message _createTextMessage(ChatMessagePB message) {
String messageId = message.messageId.toString();
/// If the message id is in the temporary map, we will use the previous fake message id
if (temporaryMessageIDMap.containsKey(messageId)) {
messageId = temporaryMessageIDMap[messageId]!;
}
return TextMessage(
author: User(id: message.authorId),
id: messageId,
text: message.content,
createdAt: message.createdAt.toInt(),
repliedMessage: _getReplyMessage(state.messages, messageId),
);
}
Message? _getReplyMessage(List<Message?> messages, String messageId) {
return messages.firstWhereOrNull((element) => element?.id == messageId);
}
}
@freezed
@ -322,15 +409,20 @@ class ChatEvent with _$ChatEvent {
) = _DidLoadPreviousMessages;
const factory ChatEvent.didLoadLatestMessages(List<Message> messages) =
_DidLoadMessages;
const factory ChatEvent.streaming(List<Message> messages) = _DidStreamMessage;
const factory ChatEvent.streaming(Message message) = _StreamingMessage;
const factory ChatEvent.receveMessage(Message message) = _ReceiveMessage;
const factory ChatEvent.didFinishStreaming() = _FinishStreamingMessage;
const factory ChatEvent.didReceiveRelatedQuestion(
List<RelatedQuestionPB> questions,
) = _DidReceiveRelatedQueston;
const factory ChatEvent.clearReleatedQuestion() = _ClearRelatedQuestion;
const factory ChatEvent.retryGenerate() = _RetryGenerate;
const factory ChatEvent.didSentUserMessage(ChatMessagePB message) =
_DidSendUserMessage;
const factory ChatEvent.didUpdateAnswerStream(
AnswerStream stream,
) = _DidUpdateAnswerStream;
const factory ChatEvent.stopStream() = _StopStream;
}
@freezed
@ -347,13 +439,14 @@ class ChatState with _$ChatState {
required LoadingState loadingPreviousStatus,
// When sending a user message, the status will be set as loading.
// After the message is sent, the status will be set as finished.
required LoadingState answerQuestionStatus,
required LoadingState streamingStatus,
// Indicate whether there are more previous messages to load.
required bool hasMorePrevMessage,
// The related questions that are received after the user message is sent.
required List<RelatedQuestionPB> relatedQuestions,
// The last user message that is sent to the server.
ChatMessagePB? lastSentMessage,
AnswerStream? answerStream,
}) = _ChatState;
factory ChatState.initial(ViewPB view, UserProfilePB userProfile) =>
@ -363,7 +456,7 @@ class ChatState with _$ChatState {
userProfile: userProfile,
initialLoadingStatus: const LoadingState.finish(),
loadingPreviousStatus: const LoadingState.finish(),
answerQuestionStatus: const LoadingState.finish(),
streamingStatus: const LoadingState.finish(),
hasMorePrevMessage: true,
relatedQuestions: [],
);
@ -377,10 +470,8 @@ class LoadingState with _$LoadingState {
enum OnetimeShotType {
unknown,
loading,
serverStreamError,
relatedQuestion,
invalidSendMesssage
invalidSendMesssage,
}
const onetimeShotType = "OnetimeShotType";
@ -388,10 +479,6 @@ const onetimeShotType = "OnetimeShotType";
extension OnetimeMessageTypeExtension on OnetimeShotType {
static OnetimeShotType fromString(String value) {
switch (value) {
case 'OnetimeShotType.loading':
return OnetimeShotType.loading;
case 'OnetimeShotType.serverStreamError':
return OnetimeShotType.serverStreamError;
case 'OnetimeShotType.relatedQuestion':
return OnetimeShotType.relatedQuestion;
case 'OnetimeShotType.invalidSendMesssage':
@ -402,7 +489,7 @@ extension OnetimeMessageTypeExtension on OnetimeShotType {
}
}
Map<String, String> toMap() {
Map<String, dynamic> toMap() {
return {
onetimeShotType: toString(),
};
@ -421,3 +508,43 @@ OnetimeShotType? onetimeMessageTypeFromMeta(Map<String, dynamic>? metadata) {
}
return null;
}
typedef AnswerStreamElement = String;
class AnswerStream {
AnswerStream() {
_port.handler = _controller.add;
_subscription = _controller.stream.listen(
(event) {
if (event.startsWith("data:")) {
_hasStarted = true;
} else if (event.startsWith("error:")) {
_error = event.substring(5);
}
},
);
}
final RawReceivePort _port = RawReceivePort();
final StreamController<AnswerStreamElement> _controller =
StreamController.broadcast();
late StreamSubscription<AnswerStreamElement> _subscription;
bool _hasStarted = false;
String? _error;
int get nativePort => _port.sendPort.nativePort;
bool get hasStarted => _hasStarted;
String? get error => _error;
Future<void> dispose() async {
await _controller.close();
await _subscription.cancel();
_port.close();
}
StreamSubscription<AnswerStreamElement> listen(
void Function(AnswerStreamElement event)? onData,
) {
return _controller.stream.listen(onData);
}
}

View File

@ -28,26 +28,23 @@ class ChatMessageListener {
ChatNotificationParser? _parser;
ChatMessageCallback? chatMessageCallback;
ChatMessageCallback? lastUserSentMessageCallback;
ChatErrorMessageCallback? chatErrorMessageCallback;
LatestMessageCallback? latestMessageCallback;
PrevMessageCallback? prevMessageCallback;
void Function()? finishAnswerQuestionCallback;
void Function()? finishStreamingCallback;
void start({
ChatMessageCallback? chatMessageCallback,
ChatErrorMessageCallback? chatErrorMessageCallback,
LatestMessageCallback? latestMessageCallback,
PrevMessageCallback? prevMessageCallback,
ChatMessageCallback? lastUserSentMessageCallback,
void Function()? finishAnswerQuestionCallback,
void Function()? finishStreamingCallback,
}) {
this.chatMessageCallback = chatMessageCallback;
this.chatErrorMessageCallback = chatErrorMessageCallback;
this.latestMessageCallback = latestMessageCallback;
this.prevMessageCallback = prevMessageCallback;
this.lastUserSentMessageCallback = lastUserSentMessageCallback;
this.finishAnswerQuestionCallback = finishAnswerQuestionCallback;
this.finishStreamingCallback = finishStreamingCallback;
}
void _callback(
@ -59,9 +56,6 @@ class ChatMessageListener {
case ChatNotification.DidReceiveChatMessage:
chatMessageCallback?.call(ChatMessagePB.fromBuffer(r));
break;
case ChatNotification.LastUserSentMessage:
lastUserSentMessageCallback?.call(ChatMessagePB.fromBuffer(r));
break;
case ChatNotification.StreamChatMessageError:
chatErrorMessageCallback?.call(ChatMessageErrorPB.fromBuffer(r));
break;
@ -71,8 +65,8 @@ class ChatMessageListener {
case ChatNotification.DidLoadPrevChatMessage:
prevMessageCallback?.call(ChatMessageListPB.fromBuffer(r));
break;
case ChatNotification.FinishAnswerQuestion:
finishAnswerQuestionCallback?.call();
case ChatNotification.FinishStreaming:
finishStreamingCallback?.call();
break;
default:
break;

View File

@ -1,103 +0,0 @@
import 'package:appflowy/plugins/ai_chat/application/chat_message_listener.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-chat/entities.pb.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'chat_related_question_bloc.freezed.dart';
class ChatRelatedMessageBloc
extends Bloc<ChatRelatedMessageEvent, ChatRelatedMessageState> {
ChatRelatedMessageBloc({
required String chatId,
}) : listener = ChatMessageListener(chatId: chatId),
super(ChatRelatedMessageState.initial()) {
on<ChatRelatedMessageEvent>(
(event, emit) async {
await event.when(
initial: () async {
listener.start(
lastUserSentMessageCallback: (message) {
if (!isClosed) {
add(ChatRelatedMessageEvent.updateLastSentMessage(message));
}
},
);
},
didReceiveRelatedQuestion: (List<RelatedQuestionPB> questions) {
Log.debug("Related questions: $questions");
emit(
state.copyWith(
relatedQuestions: questions,
),
);
},
updateLastSentMessage: (ChatMessagePB message) {
final payload =
ChatMessageIdPB(chatId: chatId, messageId: message.messageId);
ChatEventGetRelatedQuestion(payload).send().then((result) {
if (!isClosed) {
result.fold(
(list) {
add(
ChatRelatedMessageEvent.didReceiveRelatedQuestion(
list.items,
),
);
},
(err) {
Log.error("Failed to get related question: $err");
},
);
}
});
emit(
state.copyWith(
lastSentMessage: message,
relatedQuestions: [],
),
);
},
clear: () {
emit(
state.copyWith(
relatedQuestions: [],
),
);
},
);
},
);
}
final ChatMessageListener listener;
@override
Future<void> close() {
listener.stop();
return super.close();
}
}
@freezed
class ChatRelatedMessageEvent with _$ChatRelatedMessageEvent {
const factory ChatRelatedMessageEvent.initial() = Initial;
const factory ChatRelatedMessageEvent.updateLastSentMessage(
ChatMessagePB message,
) = _LastSentMessage;
const factory ChatRelatedMessageEvent.didReceiveRelatedQuestion(
List<RelatedQuestionPB> questions,
) = _RelatedQuestion;
const factory ChatRelatedMessageEvent.clear() = _Clear;
}
@freezed
class ChatRelatedMessageState with _$ChatRelatedMessageState {
const factory ChatRelatedMessageState({
ChatMessagePB? lastSentMessage,
@Default([]) List<RelatedQuestionPB> relatedQuestions,
}) = _ChatRelatedMessageState;
factory ChatRelatedMessageState.initial() => const ChatRelatedMessageState();
}

View File

@ -1,9 +1,8 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_ai_message.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_streaming_error_message.dart';
import 'package:appflowy/plugins/ai_chat/presentation/ai_message_bubble.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_related_question.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_user_message.dart';
import 'package:appflowy/plugins/ai_chat/presentation/user_message_bubble.dart';
import 'package:appflowy/workspace/presentation/home/toast.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
@ -19,11 +18,12 @@ import 'package:flutter_chat_ui/flutter_chat_ui.dart' show Chat;
import 'package:flutter_chat_types/flutter_chat_types.dart' as types;
import 'presentation/chat_input.dart';
import 'presentation/chat_loading.dart';
import 'presentation/chat_popmenu.dart';
import 'presentation/chat_theme.dart';
import 'presentation/chat_user_invalid_message.dart';
import 'presentation/chat_welcome_page.dart';
import 'presentation/message/ai_text_message.dart';
import 'presentation/message/user_text_message.dart';
class AIChatUILayout {
static EdgeInsets get chatPadding =>
@ -108,7 +108,6 @@ class _AIChatPageState extends State<AIChatPage> {
customBottomWidget: buildChatInput(blocContext),
user: _user,
theme: buildTheme(context),
customMessageBuilder: _customMessageBuilder,
onEndReached: () async {
if (state.hasMorePrevMessage &&
state.loadingPreviousStatus !=
@ -138,6 +137,13 @@ class _AIChatPageState extends State<AIChatPage> {
},
),
messageWidthRatio: AIChatUILayout.messageWidthRatio,
textMessageBuilder: (
textMessage, {
required messageWidth,
required showName,
}) {
return _buildAITextMessage(blocContext, textMessage);
},
bubbleBuilder: (
child, {
required message,
@ -149,46 +155,7 @@ class _AIChatPageState extends State<AIChatPage> {
child: child,
);
} else {
final messageType = onetimeMessageTypeFromMeta(
message.metadata,
);
if (messageType == OnetimeShotType.serverStreamError) {
return ChatStreamingError(
message: message,
onRetryPressed: () {
blocContext
.read<ChatBloc>()
.add(const ChatEvent.retryGenerate());
},
);
}
if (messageType == OnetimeShotType.invalidSendMesssage) {
return ChatInvalidUserMessage(
message: message,
);
}
if (messageType == OnetimeShotType.relatedQuestion) {
return RelatedQuestionList(
onQuestionSelected: (question) {
blocContext
.read<ChatBloc>()
.add(ChatEvent.sendMessage(question));
blocContext
.read<ChatBloc>()
.add(const ChatEvent.clearReleatedQuestion());
},
chatId: widget.view.id,
relatedQuestions: state.relatedQuestions,
);
}
return ChatAIMessageBubble(
message: message,
customMessageType: messageType,
child: child,
);
return _buildAIBubble(message, blocContext, state, child);
}
},
);
@ -199,10 +166,67 @@ class _AIChatPageState extends State<AIChatPage> {
);
}
Widget _buildAITextMessage(BuildContext context, TextMessage message) {
final isAuthor = message.author.id == _user.id;
if (isAuthor) {
return ChatTextMessageWidget(
user: message.author,
messageUserId: message.id,
text: message.text,
);
} else {
final stream = message.metadata?["$AnswerStream"];
final questionId = message.metadata?["question"];
return ChatAITextMessageWidget(
user: message.author,
messageUserId: message.id,
text: stream is AnswerStream ? stream : message.text,
key: ValueKey(message.id),
questionId: questionId,
chatId: widget.view.id,
);
}
}
Widget _buildAIBubble(
Message message,
BuildContext blocContext,
ChatState state,
Widget child,
) {
final messageType = onetimeMessageTypeFromMeta(
message.metadata,
);
if (messageType == OnetimeShotType.invalidSendMesssage) {
return ChatInvalidUserMessage(
message: message,
);
}
if (messageType == OnetimeShotType.relatedQuestion) {
return RelatedQuestionList(
onQuestionSelected: (question) {
blocContext.read<ChatBloc>().add(ChatEvent.sendMessage(question));
blocContext
.read<ChatBloc>()
.add(const ChatEvent.clearReleatedQuestion());
},
chatId: widget.view.id,
relatedQuestions: state.relatedQuestions,
);
}
return ChatAIMessageBubble(
message: message,
customMessageType: messageType,
child: child,
);
}
Widget buildBubble(Message message, Widget child) {
final isAuthor = message.author.id == _user.id;
const borderRadius = BorderRadius.all(Radius.circular(6));
final childWithPadding = isAuthor
? Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
@ -261,33 +285,25 @@ class _AIChatPageState extends State<AIChatPage> {
}
}
Widget _customMessageBuilder(
types.CustomMessage message, {
required int messageWidth,
}) {
// iteration custom message type
final messageType = onetimeMessageTypeFromMeta(message.metadata);
if (messageType == null) {
return const SizedBox.shrink();
}
switch (messageType) {
case OnetimeShotType.loading:
return const ChatAILoading();
default:
return const SizedBox.shrink();
}
}
Widget buildChatInput(BuildContext context) {
return ClipRect(
child: Padding(
padding: AIChatUILayout.safeAreaInsets(context),
child: Column(
children: [
ChatInput(
chatId: widget.view.id,
onSendPressed: (message) => onSendPressed(context, message.text),
BlocSelector<ChatBloc, ChatState, LoadingState>(
selector: (state) => state.streamingStatus,
builder: (context, state) {
return ChatInput(
chatId: widget.view.id,
onSendPressed: (message) =>
onSendPressed(context, message.text),
isStreaming: state != const LoadingState.finish(),
onStopStreaming: () {
context.read<ChatBloc>().add(const ChatEvent.stopStream());
},
);
},
),
const VSpace(6),
Opacity(

View File

@ -1,6 +1,5 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_ai_message_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_avatar.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_input.dart';
@ -13,7 +12,6 @@ import 'package:flowy_infra_ui/style_widget/icon_button.dart';
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_types/flutter_chat_types.dart';
import 'package:styled_widget/styled_widget.dart';
@ -35,30 +33,22 @@ class ChatAIMessageBubble extends StatelessWidget {
Widget build(BuildContext context) {
const padding = EdgeInsets.symmetric(horizontal: _leftPadding);
final childWithPadding = Padding(padding: padding, child: child);
final widget = isMobile
? _wrapPopMenu(childWithPadding)
: _wrapHover(childWithPadding);
return BlocProvider(
create: (context) => ChatAIMessageBloc(message: message),
child: BlocBuilder<ChatAIMessageBloc, ChatAIMessageState>(
builder: (context, state) {
final widget = isMobile
? _wrapPopMenu(childWithPadding)
: _wrapHover(childWithPadding);
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ChatBorderedCircleAvatar(
child: FlowySvg(
FlowySvgs.flowy_ai_chat_logo_s,
size: Size.square(24),
),
),
Expanded(child: widget),
],
);
},
),
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ChatBorderedCircleAvatar(
child: FlowySvg(
FlowySvgs.flowy_ai_chat_logo_s,
size: Size.square(24),
),
),
Expanded(child: widget),
],
);
}
@ -118,7 +108,7 @@ class _ChatAIMessageHoverState extends State<ChatAIMessageHover> {
borderRadius: Corners.s6Border,
),
child: Padding(
padding: const EdgeInsets.only(bottom: 40),
padding: const EdgeInsets.only(bottom: 30),
child: widget.child,
),
),

View File

@ -18,13 +18,17 @@ class ChatInput extends StatefulWidget {
required this.onSendPressed,
required this.chatId,
this.options = const InputOptions(),
required this.isStreaming,
required this.onStopStreaming,
});
final bool? isAttachmentUploading;
final VoidCallback? onAttachmentPressed;
final void Function(types.PartialText) onSendPressed;
final void Function() onStopStreaming;
final InputOptions options;
final String chatId;
final bool isStreaming;
@override
State<ChatInput> createState() => _ChatInputState();
@ -68,26 +72,23 @@ class _ChatInputState extends State<ChatInput> {
void _handleSendButtonVisibilityModeChange() {
_textController.removeListener(_handleTextControllerChange);
if (widget.options.sendButtonVisibilityMode ==
SendButtonVisibilityMode.hidden) {
_sendButtonVisible = false;
} else if (widget.options.sendButtonVisibilityMode ==
SendButtonVisibilityMode.editing) {
_sendButtonVisible = _textController.text.trim() != '';
_textController.addListener(_handleTextControllerChange);
} else {
_sendButtonVisible = true;
}
_sendButtonVisible =
_textController.text.trim() != '' || widget.isStreaming;
_textController.addListener(_handleTextControllerChange);
}
void _handleSendPressed() {
final trimmedText = _textController.text.trim();
if (trimmedText != '') {
final partialText = types.PartialText(text: trimmedText);
widget.onSendPressed(partialText);
if (widget.isStreaming) {
widget.onStopStreaming();
} else {
final trimmedText = _textController.text.trim();
if (trimmedText != '') {
final partialText = types.PartialText(text: trimmedText);
widget.onSendPressed(partialText);
if (widget.options.inputClearMode == InputClearMode.always) {
_textController.clear();
if (widget.options.inputClearMode == InputClearMode.always) {
_textController.clear();
}
}
}
}
@ -138,6 +139,7 @@ class _ChatInputState extends State<ChatInput> {
padding: textPadding,
child: TextField(
controller: _textController,
readOnly: widget.isStreaming,
focusNode: _inputFocusNode,
decoration: InputDecoration(
border: InputBorder.none,
@ -153,7 +155,6 @@ class _ChatInputState extends State<ChatInput> {
autocorrect: widget.options.autocorrect,
autofocus: widget.options.autofocus,
enableSuggestions: widget.options.enableSuggestions,
spellCheckConfiguration: const SpellCheckConfiguration(),
keyboardType: widget.options.keyboardType,
textCapitalization: TextCapitalization.sentences,
maxLines: 10,
@ -173,8 +174,14 @@ class _ChatInputState extends State<ChatInput> {
visible: _sendButtonVisible,
child: Padding(
padding: buttonPadding,
child: SendButton(
onPressed: _handleSendPressed,
child: AccessoryButton(
onSendPressed: () {
_handleSendPressed();
},
onStopStreaming: () {
widget.onStopStreaming();
},
isStreaming: widget.isStreaming,
),
),
),
@ -184,10 +191,7 @@ class _ChatInputState extends State<ChatInput> {
@override
void didUpdateWidget(covariant ChatInput oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.options.sendButtonVisibilityMode !=
oldWidget.options.sendButtonVisibilityMode) {
_handleSendButtonVisibilityModeChange();
}
_handleSendButtonVisibilityModeChange();
}
@override
@ -211,7 +215,6 @@ class InputOptions {
this.keyboardType = TextInputType.multiline,
this.onTextChanged,
this.onTextFieldTap,
this.sendButtonVisibilityMode = SendButtonVisibilityMode.editing,
this.textEditingController,
this.autocorrect = true,
this.autofocus = false,
@ -231,11 +234,6 @@ class InputOptions {
/// Will be called on [TextField] tap.
final VoidCallback? onTextFieldTap;
/// Controls the visibility behavior of the [SendButton] based on the
/// [TextField] state inside the [ChatInput] widget.
/// Defaults to [SendButtonVisibilityMode.editing].
final SendButtonVisibilityMode sendButtonVisibilityMode;
/// Custom [TextEditingController]. If not provided, defaults to the
/// [InputTextFieldController], which extends [TextEditingController] and has
/// additional fatures like markdown support. If you want to keep additional
@ -260,24 +258,46 @@ class InputOptions {
final isMobile = defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS;
class SendButton extends StatelessWidget {
const SendButton({required this.onPressed, super.key});
class AccessoryButton extends StatelessWidget {
const AccessoryButton({
required this.onSendPressed,
required this.onStopStreaming,
required this.isStreaming,
super.key,
});
final void Function() onPressed;
final void Function() onSendPressed;
final void Function() onStopStreaming;
final bool isStreaming;
@override
Widget build(BuildContext context) {
return FlowyIconButton(
width: 36,
fillColor: AFThemeExtension.of(context).lightGreyHover,
hoverColor: AFThemeExtension.of(context).lightGreyHover,
radius: BorderRadius.circular(18),
icon: FlowySvg(
FlowySvgs.send_s,
size: const Size.square(24),
color: Theme.of(context).colorScheme.primary,
),
onPressed: onPressed,
);
if (isStreaming) {
return FlowyIconButton(
width: 36,
icon: FlowySvg(
FlowySvgs.ai_stream_stop_s,
size: const Size.square(28),
color: Theme.of(context).colorScheme.primary,
),
onPressed: onStopStreaming,
radius: BorderRadius.circular(18),
fillColor: AFThemeExtension.of(context).lightGreyHover,
hoverColor: AFThemeExtension.of(context).lightGreyHover,
);
} else {
return FlowyIconButton(
width: 36,
fillColor: AFThemeExtension.of(context).lightGreyHover,
hoverColor: AFThemeExtension.of(context).lightGreyHover,
radius: BorderRadius.circular(18),
icon: FlowySvg(
FlowySvgs.send_s,
size: const Size.square(24),
color: Theme.of(context).colorScheme.primary,
),
onPressed: onSendPressed,
);
}
}
}

View File

@ -53,15 +53,15 @@ class ContentPlaceholder extends StatelessWidget {
),
],
),
Container(
width: 140,
height: 16.0,
margin: const EdgeInsets.only(bottom: 8.0),
decoration: BoxDecoration(
color: AFThemeExtension.of(context).lightGreyHover,
borderRadius: BorderRadius.circular(4.0),
),
),
// Container(
// width: 140,
// height: 16.0,
// margin: const EdgeInsets.only(bottom: 8.0),
// decoration: BoxDecoration(
// color: AFThemeExtension.of(context).lightGreyHover,
// borderRadius: BorderRadius.circular(4.0),
// ),
// ),
],
),
);

View File

@ -1,47 +1,11 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_related_question_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-chat/entities.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class RelatedQuestionPage extends StatefulWidget {
const RelatedQuestionPage({
required this.chatId,
required this.onQuestionSelected,
super.key,
});
final String chatId;
final Function(String) onQuestionSelected;
@override
State<RelatedQuestionPage> createState() => _RelatedQuestionPageState();
}
class _RelatedQuestionPageState extends State<RelatedQuestionPage> {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => ChatRelatedMessageBloc(chatId: widget.chatId)
..add(
const ChatRelatedMessageEvent.initial(),
),
child: BlocBuilder<ChatRelatedMessageBloc, ChatRelatedMessageState>(
builder: (blocContext, state) {
return RelatedQuestionList(
chatId: widget.chatId,
onQuestionSelected: widget.onQuestionSelected,
relatedQuestions: state.relatedQuestions,
);
},
),
);
}
}
class RelatedQuestionList extends StatelessWidget {
const RelatedQuestionList({

View File

@ -0,0 +1,10 @@
import 'package:flutter/widgets.dart';
class StreamTextField extends StatelessWidget {
const StreamTextField({super.key});
@override
Widget build(BuildContext context) {
return const Placeholder();
}
}

View File

@ -1,84 +1,83 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_chat_types/flutter_chat_types.dart';
// import 'package:appflowy/generated/locale_keys.g.dart';
// import 'package:easy_localization/easy_localization.dart';
// import 'package:flowy_infra_ui/flowy_infra_ui.dart';
// import 'package:flutter/material.dart';
// import 'package:flutter_chat_types/flutter_chat_types.dart';
class ChatStreamingError extends StatelessWidget {
const ChatStreamingError({
required this.message,
required this.onRetryPressed,
super.key,
});
// class ChatStreamingError extends StatelessWidget {
// const ChatStreamingError({
// required this.message,
// required this.onRetryPressed,
// super.key,
// });
final void Function() onRetryPressed;
final Message message;
@override
Widget build(BuildContext context) {
final canRetry = message.metadata?[canRetryKey] != null;
// final void Function() onRetryPressed;
// final Message message;
// @override
// Widget build(BuildContext context) {
// final canRetry = message.metadata?[canRetryKey] != null;
if (canRetry) {
return Column(
children: [
const Divider(height: 4, thickness: 1),
const VSpace(16),
Center(
child: Column(
children: [
_aiUnvaliable(),
const VSpace(10),
_retryButton(),
],
),
),
],
);
} else {
return Center(
child: Column(
children: [
const Divider(height: 20, thickness: 1),
Padding(
padding: const EdgeInsets.all(8.0),
child: FlowyText(
LocaleKeys.chat_serverUnavailable.tr(),
fontSize: 14,
),
),
],
),
);
}
}
// if (canRetry) {
// return Column(
// children: [
// const Divider(height: 4, thickness: 1),
// const VSpace(16),
// Center(
// child: Column(
// children: [
// _aiUnvaliable(),
// const VSpace(10),
// _retryButton(),
// ],
// ),
// ),
// ],
// );
// } else {
// return Center(
// child: Column(
// children: [
// const Divider(height: 20, thickness: 1),
// Padding(
// padding: const EdgeInsets.all(8.0),
// child: FlowyText(
// LocaleKeys.chat_serverUnavailable.tr(),
// fontSize: 14,
// ),
// ),
// ],
// ),
// );
// }
// }
FlowyButton _retryButton() {
return FlowyButton(
radius: BorderRadius.circular(20),
useIntrinsicWidth: true,
text: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: FlowyText(
LocaleKeys.chat_regenerateAnswer.tr(),
fontSize: 14,
),
),
onTap: onRetryPressed,
iconPadding: 0,
leftIcon: const Icon(
Icons.refresh,
size: 20,
),
);
}
// FlowyButton _retryButton() {
// return FlowyButton(
// radius: BorderRadius.circular(20),
// useIntrinsicWidth: true,
// text: Padding(
// padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
// child: FlowyText(
// LocaleKeys.chat_regenerateAnswer.tr(),
// fontSize: 14,
// ),
// ),
// onTap: onRetryPressed,
// iconPadding: 0,
// leftIcon: const Icon(
// Icons.refresh,
// size: 20,
// ),
// );
// }
Padding _aiUnvaliable() {
return Padding(
padding: const EdgeInsets.all(8.0),
child: FlowyText(
LocaleKeys.chat_aiServerUnavailable.tr(),
fontSize: 14,
),
);
}
}
// Padding _aiUnvaliable() {
// return Padding(
// padding: const EdgeInsets.all(8.0),
// child: FlowyText(
// LocaleKeys.chat_aiServerUnavailable.tr(),
// fontSize: 14,
// ),
// );
// }
// }

View File

@ -20,29 +20,33 @@ class ChatWelcomePage extends StatelessWidget {
];
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const FlowySvg(
FlowySvgs.flowy_ai_chat_logo_s,
size: Size.square(44),
),
const SizedBox(height: 40),
GridView.builder(
shrinkWrap: true,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: isMobile ? 2 : 4,
crossAxisSpacing: 6,
mainAxisSpacing: 6,
childAspectRatio: 16.0 / 9.0,
return AnimatedOpacity(
opacity: 1.0,
duration: const Duration(seconds: 3),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const FlowySvg(
FlowySvgs.flowy_ai_chat_logo_s,
size: Size.square(44),
),
itemCount: items.length,
itemBuilder: (context, index) => WelcomeQuestion(
question: items[index],
onSelected: onSelectedQuestion,
const SizedBox(height: 40),
GridView.builder(
shrinkWrap: true,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: isMobile ? 2 : 4,
crossAxisSpacing: 6,
mainAxisSpacing: 6,
childAspectRatio: 16.0 / 9.0,
),
itemCount: items.length,
itemBuilder: (context, index) => WelcomeQuestion(
question: items[index],
onSelected: onSelectedQuestion,
),
),
),
],
],
),
);
}
}

View File

@ -0,0 +1,295 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_ai_message_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_loading.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:fixnum/fixnum.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/style_widget/button.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_types/flutter_chat_types.dart';
import 'package:markdown_widget/markdown_widget.dart';
class ChatAITextMessageWidget extends StatelessWidget {
const ChatAITextMessageWidget({
super.key,
required this.user,
required this.messageUserId,
required this.text,
required this.questionId,
required this.chatId,
});
final User user;
final String messageUserId;
final dynamic text;
final Int64? questionId;
final String chatId;
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => ChatAIMessageBloc(
message: text,
chatId: chatId,
questionId: questionId,
)..add(const ChatAIMessageEvent.initial()),
child: BlocBuilder<ChatAIMessageBloc, ChatAIMessageState>(
builder: (context, state) {
if (state.error != null) {
return StreamingError(
onRetryPressed: () {
context.read<ChatAIMessageBloc>().add(
const ChatAIMessageEvent.retry(),
);
},
);
}
if (state.retryState == const LoadingState.loading()) {
return const ChatAILoading();
}
if (state.text.isEmpty) {
return const ChatAILoading();
} else {
return _textWidgetBuilder(user, context, state.text);
}
},
),
);
}
Widget _textWidgetBuilder(
User user,
BuildContext context,
String text,
) {
return MarkdownWidget(
data: text,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
config: configFromContext(context),
);
}
MarkdownConfig configFromContext(BuildContext context) {
return MarkdownConfig(
configs: [
HrConfig(color: AFThemeExtension.of(context).textColor),
ChatH1Config(
style: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 24,
fontWeight: FontWeight.bold,
height: 1.5,
),
dividerColor: AFThemeExtension.of(context).lightGreyHover,
),
ChatH2Config(
style: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 20,
fontWeight: FontWeight.bold,
height: 1.5,
),
dividerColor: AFThemeExtension.of(context).lightGreyHover,
),
ChatH3Config(
style: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 18,
fontWeight: FontWeight.bold,
height: 1.5,
),
dividerColor: AFThemeExtension.of(context).lightGreyHover,
),
H4Config(
style: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 16,
fontWeight: FontWeight.bold,
height: 1.5,
),
),
H5Config(
style: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 14,
fontWeight: FontWeight.bold,
height: 1.5,
),
),
H6Config(
style: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 12,
fontWeight: FontWeight.bold,
height: 1.5,
),
),
PreConfig(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: const BorderRadius.all(
Radius.circular(8.0),
),
),
),
PConfig(
textStyle: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 16,
fontWeight: FontWeight.w500,
height: 1.5,
),
),
CodeConfig(
style: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 16,
fontWeight: FontWeight.w500,
height: 1.5,
),
),
BlockquoteConfig.darkConfig,
],
);
}
}
class ChatH1Config extends HeadingConfig {
const ChatH1Config({
this.style = const TextStyle(
fontSize: 32,
height: 40 / 32,
fontWeight: FontWeight.bold,
),
required this.dividerColor,
});
@override
final TextStyle style;
final Color dividerColor;
@override
String get tag => MarkdownTag.h1.name;
@override
HeadingDivider? get divider => HeadingDivider(
space: 10,
color: dividerColor,
height: 10,
);
}
///config class for h2
class ChatH2Config extends HeadingConfig {
const ChatH2Config({
this.style = const TextStyle(
fontSize: 24,
height: 30 / 24,
fontWeight: FontWeight.bold,
),
required this.dividerColor,
});
@override
final TextStyle style;
final Color dividerColor;
@override
String get tag => MarkdownTag.h2.name;
@override
HeadingDivider? get divider => HeadingDivider(
space: 10,
color: dividerColor,
height: 10,
);
}
class ChatH3Config extends HeadingConfig {
const ChatH3Config({
this.style = const TextStyle(
fontSize: 24,
height: 30 / 24,
fontWeight: FontWeight.bold,
),
required this.dividerColor,
});
@override
final TextStyle style;
final Color dividerColor;
@override
String get tag => MarkdownTag.h3.name;
@override
HeadingDivider? get divider => HeadingDivider(
space: 10,
color: dividerColor,
height: 10,
);
}
class StreamingError extends StatelessWidget {
const StreamingError({
required this.onRetryPressed,
super.key,
});
final void Function() onRetryPressed;
@override
Widget build(BuildContext context) {
return Column(
children: [
const Divider(height: 4, thickness: 1),
const VSpace(16),
Center(
child: Column(
children: [
_aiUnvaliable(),
const VSpace(10),
_retryButton(),
],
),
),
],
);
}
FlowyButton _retryButton() {
return FlowyButton(
radius: BorderRadius.circular(20),
useIntrinsicWidth: true,
text: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: FlowyText(
LocaleKeys.chat_regenerateAnswer.tr(),
fontSize: 14,
),
),
onTap: onRetryPressed,
iconPadding: 0,
leftIcon: const Icon(
Icons.refresh,
size: 20,
),
);
}
Padding _aiUnvaliable() {
return Padding(
padding: const EdgeInsets.all(8.0),
child: FlowyText(
LocaleKeys.chat_aiServerUnavailable.tr(),
fontSize: 14,
),
);
}
}

View File

@ -0,0 +1,60 @@
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flutter/material.dart';
import 'package:flutter_chat_types/flutter_chat_types.dart';
class ChatTextMessageWidget extends StatelessWidget {
const ChatTextMessageWidget({
super.key,
required this.user,
required this.messageUserId,
required this.text,
});
final User user;
final String messageUserId;
final String text;
@override
Widget build(BuildContext context) {
return _textWidgetBuilder(user, context, text);
}
Widget _textWidgetBuilder(
User user,
BuildContext context,
String text,
) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextMessageText(
text: text,
),
],
);
}
}
/// Widget to reuse the markdown capabilities, e.g., for previews.
class TextMessageText extends StatelessWidget {
const TextMessageText({
super.key,
required this.text,
});
/// Text that is shown as markdown.
final String text;
@override
Widget build(BuildContext context) {
return FlowyText(
text,
fontSize: 16,
fontWeight: FontWeight.w500,
lineHeight: 1.5,
maxLines: 2000,
color: AFThemeExtension.of(context).textColor,
);
}
}

View File

@ -14,7 +14,6 @@ import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart';
import 'package:appflowy/plugins/database/widgets/card/card_bloc.dart';
import 'package:appflowy/plugins/database/widgets/cell/card_cell_style_maps/desktop_board_card_cell_style.dart';
import 'package:appflowy/plugins/database/widgets/row/row_detail.dart';
import 'package:appflowy/plugins/shared/callback_shortcuts.dart';
import 'package:appflowy/shared/conditional_listenable_builder.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
@ -29,7 +28,6 @@ import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import 'package:flutter/material.dart' hide Card;
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:provider/provider.dart';
import '../../widgets/card/card.dart';
import '../../widgets/cell/card_cell_builder.dart';
@ -322,67 +320,64 @@ class _BoardContentState extends State<_BoardContent> {
},
),
],
child: Provider(
create: (context) => AFCallbackShortcutsProvider(),
child: FocusScope(
autofocus: true,
child: BoardShortcutContainer(
focusScope: widget.focusScope,
child: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: AppFlowyBoard(
boardScrollController: scrollManager,
scrollController: scrollController,
controller: context.read<BoardBloc>().boardController,
groupConstraints: const BoxConstraints.tightFor(width: 256),
config: config,
leading: HiddenGroupsColumn(margin: config.groupHeaderPadding),
trailing: context
.read<BoardBloc>()
.groupingFieldType
?.canCreateNewGroup ??
false
? BoardTrailing(scrollController: scrollController)
: const HSpace(40),
headerBuilder: (_, groupData) => BlocProvider<BoardBloc>.value(
value: context.read<BoardBloc>(),
child: BoardColumnHeader(
groupData: groupData,
margin: config.groupHeaderPadding,
),
child: FocusScope(
autofocus: true,
child: BoardShortcutContainer(
focusScope: widget.focusScope,
child: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: AppFlowyBoard(
boardScrollController: scrollManager,
scrollController: scrollController,
controller: context.read<BoardBloc>().boardController,
groupConstraints: const BoxConstraints.tightFor(width: 256),
config: config,
leading: HiddenGroupsColumn(margin: config.groupHeaderPadding),
trailing: context
.read<BoardBloc>()
.groupingFieldType
?.canCreateNewGroup ??
false
? BoardTrailing(scrollController: scrollController)
: const HSpace(40),
headerBuilder: (_, groupData) => BlocProvider<BoardBloc>.value(
value: context.read<BoardBloc>(),
child: BoardColumnHeader(
groupData: groupData,
margin: config.groupHeaderPadding,
),
footerBuilder: (_, groupData) => MultiBlocProvider(
providers: [
BlocProvider.value(
value: context.read<BoardBloc>(),
),
BlocProvider.value(
value: context.read<BoardActionsCubit>(),
),
],
child: BoardColumnFooter(
columnData: groupData,
boardConfig: config,
scrollManager: scrollManager,
),
footerBuilder: (_, groupData) => MultiBlocProvider(
providers: [
BlocProvider.value(
value: context.read<BoardBloc>(),
),
BlocProvider.value(
value: context.read<BoardActionsCubit>(),
),
],
child: BoardColumnFooter(
columnData: groupData,
boardConfig: config,
scrollManager: scrollManager,
),
cardBuilder: (_, column, columnItem) => MultiBlocProvider(
key: ValueKey("board_card_${column.id}_${columnItem.id}"),
providers: [
BlocProvider<BoardBloc>.value(
value: context.read<BoardBloc>(),
),
BlocProvider.value(
value: context.read<BoardActionsCubit>(),
),
],
child: _BoardCard(
afGroupData: column,
groupItem: columnItem as GroupItem,
boardConfig: config,
notifier: widget.focusScope,
cellBuilder: cellBuilder,
),
cardBuilder: (_, column, columnItem) => MultiBlocProvider(
key: ValueKey("board_card_${column.id}_${columnItem.id}"),
providers: [
BlocProvider<BoardBloc>.value(
value: context.read<BoardBloc>(),
),
BlocProvider.value(
value: context.read<BoardActionsCubit>(),
),
],
child: _BoardCard(
afGroupData: column,
groupItem: columnItem as GroupItem,
boardConfig: config,
notifier: widget.focusScope,
cellBuilder: cellBuilder,
),
),
),

View File

@ -3,7 +3,6 @@ import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/board/application/board_bloc.dart';
import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/header/field_type_extension.dart';
import 'package:appflowy/plugins/shared/callback_shortcuts.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:appflowy_board/appflowy_board.dart';
@ -32,7 +31,6 @@ class BoardColumnHeader extends StatefulWidget {
class _BoardColumnHeaderState extends State<BoardColumnHeader> {
final FocusNode _focusNode = FocusNode();
final FocusNode _keyboardListenerFocusNode = FocusNode();
late final AFCallbackShortcutsProvider _shortcutsProvider;
late final TextEditingController _controller =
TextEditingController.fromValue(
@ -47,21 +45,15 @@ class _BoardColumnHeaderState extends State<BoardColumnHeader> {
@override
void initState() {
super.initState();
_shortcutsProvider = context.read<AFCallbackShortcutsProvider>();
_focusNode.addListener(() {
if (!_focusNode.hasFocus) {
_saveEdit();
}
});
_keyboardListenerFocusNode.addListener(() {
_shortcutsProvider.isShortcutsEnabled.value =
!_keyboardListenerFocusNode.hasFocus;
});
}
@override
void dispose() {
_shortcutsProvider.isShortcutsEnabled.value = true;
_focusNode.dispose();
_keyboardListenerFocusNode.dispose();
_controller.dispose();

View File

@ -39,14 +39,14 @@ class BoardFocusScope extends ChangeNotifier
notifyListeners();
}
void focusNext() {
bool focusNext() {
_deepCopy();
// if no card is focused, focus on the first card in the board
if (_focusedCards.isEmpty) {
_focusFirstCard();
notifyListeners();
return;
return true;
}
final lastFocusedCard = _focusedCards.last;
@ -58,7 +58,7 @@ class BoardFocusScope extends ChangeNotifier
if (iterable == null || iterable.isEmpty) {
_focusFirstCard();
notifyListeners();
return;
return true;
}
if (iterable.length == 1) {
@ -90,16 +90,18 @@ class BoardFocusScope extends ChangeNotifier
}
notifyListeners();
return true;
}
void focusPrevious() {
bool focusPrevious() {
_deepCopy();
// if no card is focused, focus on the last card in the board
if (_focusedCards.isEmpty) {
_focusLastCard();
notifyListeners();
return;
return true;
}
final lastFocusedCard = _focusedCards.last;
@ -111,7 +113,7 @@ class BoardFocusScope extends ChangeNotifier
if (iterable == null || iterable.isEmpty) {
_focusLastCard();
notifyListeners();
return;
return true;
}
if (iterable.length == 1) {
@ -143,16 +145,18 @@ class BoardFocusScope extends ChangeNotifier
}
notifyListeners();
return true;
}
void adjustRangeDown() {
bool adjustRangeDown() {
_deepCopy();
// if no card is focused, focus on the first card in the board
if (_focusedCards.isEmpty) {
_focusFirstCard();
notifyListeners();
return;
return true;
}
final firstFocusedCard = _focusedCards.first;
@ -171,7 +175,7 @@ class BoardFocusScope extends ChangeNotifier
if (firstGroupIndex == -1 || lastGroupIndex == -1) {
_focusFirstCard();
notifyListeners();
return;
return true;
}
if (firstGroupIndex < lastGroupIndex) {
@ -189,7 +193,7 @@ class BoardFocusScope extends ChangeNotifier
if (firstCardIndex == -1 || lastCardIndex == -1) {
_focusFirstCard();
notifyListeners();
return;
return true;
}
isExpand = firstCardIndex < lastCardIndex;
@ -203,7 +207,7 @@ class BoardFocusScope extends ChangeNotifier
if (groupController == null) {
_focusFirstCard();
notifyListeners();
return;
return true;
}
final iterable = groupController.items
@ -236,16 +240,17 @@ class BoardFocusScope extends ChangeNotifier
}
notifyListeners();
return true;
}
void adjustRangeUp() {
bool adjustRangeUp() {
_deepCopy();
// if no card is focused, focus on the first card in the board
if (_focusedCards.isEmpty) {
_focusLastCard();
notifyListeners();
return;
return true;
}
final firstFocusedCard = _focusedCards.first;
@ -264,7 +269,7 @@ class BoardFocusScope extends ChangeNotifier
if (firstGroupIndex == -1 || lastGroupIndex == -1) {
_focusLastCard();
notifyListeners();
return;
return true;
}
if (firstGroupIndex < lastGroupIndex) {
@ -282,7 +287,7 @@ class BoardFocusScope extends ChangeNotifier
if (firstCardIndex == -1 || lastCardIndex == -1) {
_focusLastCard();
notifyListeners();
return;
return true;
}
isExpand = firstCardIndex > lastCardIndex;
@ -296,7 +301,7 @@ class BoardFocusScope extends ChangeNotifier
if (groupController == null) {
_focusLastCard();
notifyListeners();
return;
return true;
}
final iterable = groupController.items.reversed
@ -329,12 +334,15 @@ class BoardFocusScope extends ChangeNotifier
}
notifyListeners();
return true;
}
void clear() {
bool clear() {
_deepCopy();
_focusedCards.clear();
notifyListeners();
return true;
}
void _focusFirstCard() {

View File

@ -22,56 +22,7 @@ class BoardShortcutContainer extends StatelessWidget {
@override
Widget build(BuildContext context) {
return AFCallbackShortcuts(
canAcceptEvent: (_, __) =>
context.read<AFCallbackShortcutsProvider>().isShortcutsEnabled.value,
bindings: {
const SingleActivator(LogicalKeyboardKey.arrowUp):
focusScope.focusPrevious,
const SingleActivator(LogicalKeyboardKey.arrowDown):
focusScope.focusNext,
const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true):
focusScope.adjustRangeUp,
const SingleActivator(LogicalKeyboardKey.arrowDown, shift: true):
focusScope.adjustRangeDown,
const SingleActivator(LogicalKeyboardKey.escape): focusScope.clear,
const SingleActivator(LogicalKeyboardKey.keyE): () {
if (focusScope.value.length != 1) {
return;
}
context
.read<BoardActionsCubit>()
.startEditingRow(focusScope.value.first);
},
const SingleActivator(LogicalKeyboardKey.keyN): () {
if (focusScope.value.length != 1) {
return;
}
context
.read<BoardActionsCubit>()
.startCreateBottomRow(focusScope.value.first.groupId);
focusScope.clear();
},
const SingleActivator(LogicalKeyboardKey.delete): () =>
_removeHandler(context),
const SingleActivator(LogicalKeyboardKey.backspace): () =>
_removeHandler(context),
SingleActivator(
LogicalKeyboardKey.arrowUp,
shift: true,
meta: Platform.isMacOS,
control: !Platform.isMacOS,
): () => _shiftCmdUpHandler(context),
const SingleActivator(LogicalKeyboardKey.enter): () =>
_enterHandler(context),
const SingleActivator(LogicalKeyboardKey.numpadEnter): () =>
_enterHandler(context),
const SingleActivator(LogicalKeyboardKey.enter, shift: true): () =>
_shitEnterHandler(context),
const SingleActivator(LogicalKeyboardKey.comma): () =>
_moveGroupToAdjacentGroup(context, true),
const SingleActivator(LogicalKeyboardKey.period): () =>
_moveGroupToAdjacentGroup(context, false),
},
bindings: _shortcutBindings(context),
child: FocusScope(
child: Focus(
child: Builder(
@ -92,16 +43,75 @@ class BoardShortcutContainer extends StatelessWidget {
);
}
void _enterHandler(BuildContext context) {
Map<ShortcutActivator, AFBindingCallback> _shortcutBindings(
BuildContext context,
) {
return {
const SingleActivator(LogicalKeyboardKey.arrowUp):
focusScope.focusPrevious,
const SingleActivator(LogicalKeyboardKey.arrowDown): focusScope.focusNext,
const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true):
focusScope.adjustRangeUp,
const SingleActivator(LogicalKeyboardKey.arrowDown, shift: true):
focusScope.adjustRangeDown,
const SingleActivator(LogicalKeyboardKey.escape): focusScope.clear,
const SingleActivator(LogicalKeyboardKey.delete): () =>
_removeHandler(context),
const SingleActivator(LogicalKeyboardKey.backspace): () =>
_removeHandler(context),
SingleActivator(
LogicalKeyboardKey.arrowUp,
shift: true,
meta: Platform.isMacOS,
control: !Platform.isMacOS,
): () => _shiftCmdUpHandler(context),
const SingleActivator(LogicalKeyboardKey.enter): () =>
_enterHandler(context),
const SingleActivator(LogicalKeyboardKey.numpadEnter): () =>
_enterHandler(context),
const SingleActivator(LogicalKeyboardKey.enter, shift: true): () =>
_shiftEnterHandler(context),
const SingleActivator(LogicalKeyboardKey.comma): () =>
_moveGroupToAdjacentGroup(context, true),
const SingleActivator(LogicalKeyboardKey.period): () =>
_moveGroupToAdjacentGroup(context, false),
const SingleActivator(LogicalKeyboardKey.keyE): () =>
_keyEHandler(context),
const SingleActivator(LogicalKeyboardKey.keyN): () =>
_keyNHandler(context),
};
}
bool _keyEHandler(BuildContext context) {
if (focusScope.value.length != 1) {
return;
return false;
}
context.read<BoardActionsCubit>().startEditingRow(focusScope.value.first);
return true;
}
bool _keyNHandler(BuildContext context) {
if (focusScope.value.length != 1) {
return false;
}
context
.read<BoardActionsCubit>()
.startCreateBottomRow(focusScope.value.first.groupId);
focusScope.clear();
return true;
}
bool _enterHandler(BuildContext context) {
if (focusScope.value.length != 1) {
return false;
}
context
.read<BoardActionsCubit>()
.openCardWithRowId(focusScope.value.first.rowId);
return true;
}
void _shitEnterHandler(BuildContext context) {
bool _shiftEnterHandler(BuildContext context) {
if (focusScope.value.isEmpty) {
context
.read<BoardActionsCubit>()
@ -111,10 +121,13 @@ class BoardShortcutContainer extends StatelessWidget {
focusScope.value.first,
CreateBoardCardRelativePosition.after,
);
} else {
return false;
}
return true;
}
void _shiftCmdUpHandler(BuildContext context) {
bool _shiftCmdUpHandler(BuildContext context) {
if (focusScope.value.isEmpty) {
context
.read<BoardActionsCubit>()
@ -124,19 +137,23 @@ class BoardShortcutContainer extends StatelessWidget {
focusScope.value.first,
CreateBoardCardRelativePosition.before,
);
} else {
return false;
}
return true;
}
void _removeHandler(BuildContext context) {
if (focusScope.value.isEmpty) {
return;
bool _removeHandler(BuildContext context) {
if (focusScope.value.length != 1) {
return false;
}
context.read<BoardBloc>().add(BoardEvent.deleteCards(focusScope.value));
return true;
}
void _moveGroupToAdjacentGroup(BuildContext context, bool toPrevious) {
bool _moveGroupToAdjacentGroup(BuildContext context, bool toPrevious) {
if (focusScope.value.length != 1) {
return;
return false;
}
context.read<BoardBloc>().add(
BoardEvent.moveGroupToAdjacentGroup(
@ -145,5 +162,6 @@ class BoardShortcutContainer extends StatelessWidget {
),
);
focusScope.clear();
return true;
}
}

View File

@ -1,9 +1,7 @@
import 'package:appflowy/plugins/database/application/database_controller.dart';
import 'package:appflowy/plugins/database/application/tab_bar_bloc.dart';
import 'package:appflowy/plugins/database/widgets/share_button.dart';
import 'package:appflowy/plugins/shared/sync_indicator.dart';
import 'package:appflowy/plugins/util.dart';
import 'package:appflowy/shared/feature_flags.dart';
import 'package:appflowy/startup/plugin/plugin.dart';
import 'package:appflowy/workspace/application/view/view_bloc.dart';
import 'package:appflowy/workspace/application/view_info/view_info_bloc.dart';
@ -272,15 +270,6 @@ class DatabasePluginWidgetBuilder extends PluginWidgetBuilder {
value: bloc,
child: Row(
children: [
...FeatureFlag.syncDatabase.isOn
? [
DatabaseSyncIndicator(
key: ValueKey('sync_state_${view.id}'),
view: view,
),
const HSpace(16),
]
: [],
DatabaseShareButton(key: ValueKey(view.id), view: view),
const HSpace(10),
ViewFavoriteButton(view: view),

View File

@ -118,10 +118,6 @@ class _DatabaseDocumentPageState extends State<DatabaseDocumentPage> {
return Column(
children: [
// Only show the indicator in integration test mode
// if (FlowyRunner.currentMode.isIntegrationTest)
// const DocumentSyncIndicator(),
if (state.isDeleted) _buildBanner(context),
Expanded(child: appflowyEditorPage),
],

View File

@ -6,7 +6,6 @@ import 'package:appflowy/plugins/document/application/document_appearance_cubit.
import 'package:appflowy/plugins/document/document_page.dart';
import 'package:appflowy/plugins/document/presentation/document_collaborators.dart';
import 'package:appflowy/plugins/document/presentation/share/share_button.dart';
import 'package:appflowy/plugins/shared/sync_indicator.dart';
import 'package:appflowy/plugins/util.dart';
import 'package:appflowy/shared/feature_flags.dart';
import 'package:appflowy/startup/plugin/plugin.dart';
@ -153,11 +152,6 @@ class DocumentPluginWidgetBuilder extends PluginWidgetBuilder
view: view,
),
const HSpace(16),
DocumentSyncIndicator(
key: ValueKey('sync_state_${view.id}'),
view: view,
),
const HSpace(16),
]
: [const HSpace(8)],
DocumentShareButton(

View File

@ -139,10 +139,6 @@ class _DocumentPageState extends State<DocumentPage>
return Column(
children: [
// Only show the indicator in integration test mode
// if (FlowyRunner.currentMode.isIntegrationTest)
// const DocumentSyncIndicator(),
if (state.isDeleted) _buildBanner(context),
Expanded(child: child),
],

View File

@ -1,26 +1,24 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class AFCallbackShortcutsProvider {
final ValueNotifier<bool> isShortcutsEnabled = ValueNotifier(true);
}
typedef AFBindingCallback = bool Function();
class AFCallbackShortcuts extends StatelessWidget {
const AFCallbackShortcuts({
super.key,
required this.bindings,
required this.canAcceptEvent,
required this.child,
});
final Map<ShortcutActivator, VoidCallback> bindings;
final bool Function(FocusNode node, KeyEvent event) canAcceptEvent;
// The bindings for the shortcuts
//
// The result of the callback will be used to determine if the event is handled
final Map<ShortcutActivator, AFBindingCallback> bindings;
final Widget child;
bool _applyKeyEventBinding(ShortcutActivator activator, KeyEvent event) {
if (activator.accepts(event, HardwareKeyboard.instance)) {
bindings[activator]!.call();
return true;
return bindings[activator]?.call() ?? false;
}
return false;
}
@ -31,9 +29,6 @@ class AFCallbackShortcuts extends StatelessWidget {
canRequestFocus: false,
skipTraversal: true,
onKeyEvent: (FocusNode node, KeyEvent event) {
if (!canAcceptEvent(node, event)) {
return KeyEventResult.ignored;
}
KeyEventResult result = KeyEventResult.ignored;
for (final ShortcutActivator activator in bindings.keys) {
result = _applyKeyEventBinding(activator, event)

View File

@ -85,6 +85,8 @@ enum FeatureFlag {
bool get isOn {
if ([
// release this feature in version 0.5.9
FeatureFlag.search,
// release this feature in version 0.5.6
FeatureFlag.collaborativeWorkspace,
FeatureFlag.membersSettings,

View File

@ -1,12 +1,14 @@
import 'dart:async';
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:appflowy/core/helpers/helpers.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/startup/tasks/app_window_size_manager.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:bitsdojo_window/bitsdojo_window.dart';
import 'package:scaled_app/scaled_app.dart';
import 'package:window_manager/window_manager.dart';
@ -17,7 +19,7 @@ class InitAppWindowTask extends LaunchTask with WindowListener {
final String title;
final windowsManager = WindowSizeManager();
final windowSizeManager = WindowSizeManager();
@override
Future<void> initialize(LaunchContext context) async {
@ -29,7 +31,7 @@ class InitAppWindowTask extends LaunchTask with WindowListener {
await windowManager.ensureInitialized();
windowManager.addListener(this);
final windowSize = await windowsManager.getSize();
final windowSize = await windowSizeManager.getSize();
final windowOptions = WindowOptions(
size: windowSize,
minimumSize: const Size(
@ -43,23 +45,38 @@ class InitAppWindowTask extends LaunchTask with WindowListener {
title: title,
);
await windowManager.waitUntilReadyToShow(windowOptions, () async {
await windowManager.show();
await windowManager.focus();
final position = await windowSizeManager.getPosition();
if (PlatformExtension.isWindows) {
// Hide title bar on Windows, we implement a custom solution elsewhere
await windowManager.setTitleBarStyle(TitleBarStyle.hidden);
}
if (PlatformExtension.isWindows) {
doWhenWindowReady(() {
appWindow.minSize = windowOptions.minimumSize;
appWindow.maxSize = windowOptions.maximumSize;
appWindow.size = windowSize;
final position = await windowsManager.getPosition();
if (position != null) {
await windowManager.setPosition(position);
}
});
if (position != null) {
appWindow.position = position;
}
appWindow.show();
});
} else {
await windowManager.waitUntilReadyToShow(windowOptions, () async {
await windowManager.show();
await windowManager.focus();
if (PlatformExtension.isWindows) {
// Hide title bar on Windows, we implement a custom solution elsewhere
await windowManager.setTitleBarStyle(TitleBarStyle.hidden);
}
if (position != null) {
await windowManager.setPosition(position);
}
});
}
unawaited(
windowsManager.getScaleFactor().then(
windowSizeManager.getScaleFactor().then(
(v) => ScaledWidgetsFlutterBinding.instance.scaleFactor = (_) => v,
),
);
@ -70,7 +87,7 @@ class InitAppWindowTask extends LaunchTask with WindowListener {
super.onWindowResize();
final currentWindowSize = await windowManager.getSize();
return windowsManager.setSize(currentWindowSize);
return windowSizeManager.setSize(currentWindowSize);
}
@override
@ -78,7 +95,7 @@ class InitAppWindowTask extends LaunchTask with WindowListener {
super.onWindowMaximize();
final currentWindowSize = await windowManager.getSize();
return windowsManager.setSize(currentWindowSize);
return windowSizeManager.setSize(currentWindowSize);
}
@override
@ -86,7 +103,7 @@ class InitAppWindowTask extends LaunchTask with WindowListener {
super.onWindowMoved();
final position = await windowManager.getPosition();
return windowsManager.setPosition(position);
return windowSizeManager.setPosition(position);
}
@override

View File

@ -60,7 +60,8 @@ class FavoriteBloc extends Bloc<FavoriteEvent, FavoriteState> {
);
},
toggle: (view) async {
if (view.isFavorite) {
final isFavorited = state.views.any((v) => v.item.id == view.id);
if (isFavorited) {
await _service.unpinFavorite(view);
} else if (state.pinnedViews.length < 3) {
// pin the view if there are less than 3 pinned views

View File

@ -34,7 +34,8 @@ class FavoriteService {
bool isPinned,
) async {
try {
final current = view.extra.isNotEmpty ? jsonDecode(view.extra) : {};
final current =
view.extra.isNotEmpty ? jsonDecode(view.extra) : <String, dynamic>{};
final merged = mergeMaps(
current,
<String, dynamic>{ViewExtKeys.isPinnedKey: isPinned},

View File

@ -12,12 +12,37 @@ import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class SearchField extends StatelessWidget {
class SearchField extends StatefulWidget {
const SearchField({super.key, this.query, this.isLoading = false});
final String? query;
final bool isLoading;
@override
State<SearchField> createState() => _SearchFieldState();
}
class _SearchFieldState extends State<SearchField> {
final focusNode = FocusNode();
late final controller = TextEditingController(text: widget.query);
@override
void initState() {
super.initState();
focusNode.requestFocus();
controller.selection = TextSelection(
baseOffset: 0,
extentOffset: controller.text.length,
);
}
@override
void dispose() {
focusNode.dispose();
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Row(
@ -29,7 +54,8 @@ class SearchField extends StatelessWidget {
),
Expanded(
child: FlowyTextField(
controller: TextEditingController(text: query),
focusNode: focusNode,
controller: controller,
textStyle:
Theme.of(context).textTheme.bodySmall?.copyWith(fontSize: 14),
decoration: InputDecoration(
@ -84,7 +110,7 @@ class SearchField extends StatelessWidget {
.add(CommandPaletteEvent.searchChanged(search: value)),
),
),
if (isLoading) ...[
if (widget.isLoading) ...[
const HSpace(12),
FlowyTooltip(
message: LocaleKeys.commandPalette_loadingTooltip.tr(),

View File

@ -56,7 +56,7 @@ class SidebarTopMenu extends StatelessWidget {
: FlowySvgs.flowy_logo_text_xl;
return Padding(
padding: const EdgeInsets.only(top: 12.0, left: 4),
padding: const EdgeInsets.only(top: 12.0, left: 8),
child: FlowySvg(
svgData,
size: const Size(92, 17),

View File

@ -333,7 +333,8 @@ class _SidebarSearchButton extends StatelessWidget {
return FlowyButton(
onTap: () => CommandPalette.of(context).toggle(),
leftIcon: const FlowySvg(FlowySvgs.search_s),
iconPadding: 10.0,
iconPadding: 12.0,
margin: const EdgeInsets.only(left: 8.0),
text: FlowyText.regular(LocaleKeys.search_label.tr()),
);
}

View File

@ -40,7 +40,7 @@ class _WorkspaceIconState extends State<WorkspaceIcon> {
Widget build(BuildContext context) {
Widget child = widget.workspace.icon.isNotEmpty
? Container(
width: widget.emojiSize ?? widget.iconSize,
width: widget.iconSize,
alignment: widget.alignment ?? Alignment.center,
child: FlowyText.emoji(
widget.workspace.icon,

View File

@ -35,7 +35,7 @@ class ViewFavoriteButton extends StatelessWidget {
child: FlowySvg(
isFavorite ? FlowySvgs.favorited_s : FlowySvgs.favorite_s,
size: const Size.square(18),
blendMode: null,
blendMode: isFavorite ? null : BlendMode.srcIn,
),
),
),

View File

@ -143,6 +143,9 @@ class FlowyText extends StatelessWidget {
if (fontFamily != null && fallbackFontFamily == null) {
fallbackFontFamily = [fontFamily];
}
}
if (isEmoji && (_useNotoColorEmoji || Platform.isWindows)) {
fontSize = fontSize * 0.8;
}
@ -170,7 +173,7 @@ class FlowyText extends StatelessWidget {
textAlign: textAlign,
overflow: overflow ?? TextOverflow.clip,
style: textStyle,
strutStyle: Platform.isMacOS
strutStyle: (Platform.isMacOS || Platform.isLinux) & !isEmoji
? StrutStyle.fromTextStyle(
textStyle,
forceStrutHeight: true,
@ -199,6 +202,5 @@ class FlowyText extends StatelessWidget {
return null;
}
bool get _useNotoColorEmoji =>
Platform.isLinux || Platform.isAndroid || Platform.isWindows;
bool get _useNotoColorEmoji => Platform.isLinux || Platform.isAndroid;
}

View File

@ -120,6 +120,46 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.2.0"
bitsdojo_window:
dependency: "direct main"
description:
name: bitsdojo_window
sha256: "88ef7765dafe52d97d7a3684960fb5d003e3151e662c18645c1641c22b873195"
url: "https://pub.dev"
source: hosted
version: "0.1.6"
bitsdojo_window_linux:
dependency: transitive
description:
name: bitsdojo_window_linux
sha256: "9519c0614f98be733e0b1b7cb15b827007886f6fe36a4fb62cf3d35b9dd578ab"
url: "https://pub.dev"
source: hosted
version: "0.1.4"
bitsdojo_window_macos:
dependency: transitive
description:
name: bitsdojo_window_macos
sha256: f7c5be82e74568c68c5b8449e2c5d8fd12ec195ecd70745a7b9c0f802bb0268f
url: "https://pub.dev"
source: hosted
version: "0.1.4"
bitsdojo_window_platform_interface:
dependency: transitive
description:
name: bitsdojo_window_platform_interface
sha256: "65daa015a0c6dba749bdd35a0f092e7a8ba8b0766aa0480eb3ef808086f6e27c"
url: "https://pub.dev"
source: hosted
version: "0.1.2"
bitsdojo_window_windows:
dependency: transitive
description:
name: bitsdojo_window_windows
sha256: fa982cf61ede53f483e50b257344a1c250af231a3cdc93a7064dd6dc0d720b68
url: "https://pub.dev"
source: hosted
version: "0.1.6"
bloc:
dependency: "direct main"
description:
@ -640,6 +680,14 @@ packages:
url: "https://github.com/LucasXu0/emoji_mart.git"
source: git
version: "1.0.2"
flutter_highlight:
dependency: transitive
description:
name: flutter_highlight
sha256: "7b96333867aa07e122e245c033b8ad622e4e3a42a1a2372cbb098a2541d8782c"
url: "https://pub.dev"
source: hosted
version: "0.7.0"
flutter_link_previewer:
dependency: transitive
description:
@ -1026,7 +1074,7 @@ packages:
source: hosted
version: "0.6.0"
isolates:
dependency: transitive
dependency: "direct main"
description:
name: isolates
sha256: ce89e4141b27b877326d3715be2dceac7a7ba89f3229785816d2d318a75ddf28
@ -1169,6 +1217,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.2.2"
markdown_widget:
dependency: "direct main"
description:
name: markdown_widget
sha256: "216dced98962d7699a265344624bc280489d739654585ee881c95563a3252fac"
url: "https://pub.dev"
source: hosted
version: "2.3.2+6"
matcher:
dependency: transitive
description:
@ -2405,10 +2461,10 @@ packages:
dependency: "direct main"
description:
name: window_manager
sha256: b3c895bdf936c77b83c5254bec2e6b3f066710c1f89c38b20b8acc382b525494
sha256: "8699323b30da4cdbe2aa2e7c9de567a6abd8a97d9a5c850a3c86dcd0b34bbfbf"
url: "https://pub.dev"
source: hosted
version: "0.3.8"
version: "0.3.9"
xdg_directories:
dependency: transitive
description:

View File

@ -94,7 +94,6 @@ dependencies:
git:
url: https://github.com/Xazin/flutter_calendar_view
ref: "6fe0c98"
window_manager: ^0.3.4
http: ^1.0.0
path: ^1.8.3
mocktail: ^1.0.1
@ -119,6 +118,7 @@ dependencies:
# TODO: Consider implementing custom package
# to gather notification handling for all platforms
local_notifier: ^0.1.5
app_links: ^3.5.0
flutter_slidable: ^3.0.0
image_picker: ^1.0.4
@ -141,6 +141,14 @@ dependencies:
auto_size_text_field: ^2.2.3
reorderable_tabbar: ^1.0.6
shimmer: ^3.0.0
isolates: ^3.0.3+8
markdown_widget: ^2.3.2+6
# Window Manager for MacOS and Linux
window_manager: ^0.3.9
# BitsDojo Window for Windows
bitsdojo_window: ^0.1.6
dev_dependencies:
flutter_lints: ^3.0.1

View File

@ -0,0 +1,16 @@
import 'package:flutter_test/flutter_test.dart';
import 'util.dart';
void main() {
// ignore: unused_local_variable
late AppFlowyChatTest chatTest;
setUpAll(() async {
chatTest = await AppFlowyChatTest.ensureInitialized();
});
test('send message', () async {
// final context = await chatTest.createChat();
});
}

View File

@ -0,0 +1,44 @@
import 'package:appflowy/plugins/ai_chat/chat.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import '../../util.dart';
class AppFlowyChatTest {
AppFlowyChatTest({required this.unitTest});
final AppFlowyUnitTest unitTest;
static Future<AppFlowyChatTest> ensureInitialized() async {
final inner = await AppFlowyUnitTest.ensureInitialized();
return AppFlowyChatTest(unitTest: inner);
}
Future<ViewPB> createChat() async {
final app = await unitTest.createWorkspace();
final builder = AIChatPluginBuilder();
return ViewBackendService.createView(
parentViewId: app.id,
name: "Test Chat",
layoutType: builder.layoutType,
openAfterCreate: true,
).then((result) {
return result.fold(
(view) async {
return view;
},
(error) {
throw Exception();
},
);
});
}
}
Future<void> boardResponseFuture() {
return Future.delayed(boardResponseDuration());
}
Duration boardResponseDuration({int milliseconds = 200}) {
return Duration(milliseconds: milliseconds);
}

View File

@ -58,7 +58,6 @@ class AppFlowyUnitTest {
}
WorkspacePB get currentWorkspace => workspace;
Future<void> _loadWorkspace() async {
final result = await userService.getCurrentWorkspace();
result.fold(
@ -83,15 +82,6 @@ class AppFlowyUnitTest {
(error) => throw Exception(error),
);
}
Future<List<ViewPB>> loadApps() async {
final result = await workspaceService.getPublicViews();
return result.fold(
(apps) => apps,
(error) => throw Exception(error),
);
}
}
void _pathProviderInitialized() {

View File

@ -17,7 +17,7 @@ bool FlutterWindow::OnCreate() {
RECT frame = GetClientArea();
// The size here must match the window dimensions to avoid unnecessary surface
// creation / destruction in the startup path.
// creation / destruction in the startup path.
flutter_controller_ = std::make_unique<flutter::FlutterViewController>(
frame.right - frame.left, frame.bottom - frame.top, project_);
// Ensure that basic setup of the controller was successful.

View File

@ -5,6 +5,9 @@
#include "flutter_window.h"
#include "utils.h"
#include <bitsdojo_window_windows/bitsdojo_window_plugin.h>
auto bdw = bitsdojo_window_configure(BDW_CUSTOM_FRAME | BDW_HIDE_ON_STARTUP);
int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
_In_ wchar_t *command_line, _In_ int show_command) {
HANDLE hMutexInstance = CreateMutex(NULL, TRUE, L"AppFlowyMutex");

View File

@ -117,6 +117,16 @@ dependencies = [
"memchr",
]
[[package]]
name = "allo-isolate"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97b6d794345b06592d0ebeed8e477e41b71e5a0a49df4fc0e4184d5938b99509"
dependencies = [
"atomic",
"pin-project",
]
[[package]]
name = "alloc-no-stdlib"
version = "2.0.4"
@ -162,7 +172,7 @@ checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da"
[[package]]
name = "app-error"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=3f55cea9ca386875a1668ef30600c83cd6a1ffe2#3f55cea9ca386875a1668ef30600c83cd6a1ffe2"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=d0467e7e2e8ee4b925556b5510fb6ed7322dde8c#d0467e7e2e8ee4b925556b5510fb6ed7322dde8c"
dependencies = [
"anyhow",
"bincode",
@ -182,7 +192,7 @@ dependencies = [
[[package]]
name = "appflowy-ai-client"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=3f55cea9ca386875a1668ef30600c83cd6a1ffe2#3f55cea9ca386875a1668ef30600c83cd6a1ffe2"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=d0467e7e2e8ee4b925556b5510fb6ed7322dde8c#d0467e7e2e8ee4b925556b5510fb6ed7322dde8c"
dependencies = [
"anyhow",
"bytes",
@ -289,6 +299,12 @@ dependencies = [
"system-deps 6.1.1",
]
[[package]]
name = "atomic"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba"
[[package]]
name = "atomic_refcell"
version = "0.1.10"
@ -756,7 +772,7 @@ dependencies = [
[[package]]
name = "client-api"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=3f55cea9ca386875a1668ef30600c83cd6a1ffe2#3f55cea9ca386875a1668ef30600c83cd6a1ffe2"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=d0467e7e2e8ee4b925556b5510fb6ed7322dde8c#d0467e7e2e8ee4b925556b5510fb6ed7322dde8c"
dependencies = [
"again",
"anyhow",
@ -803,7 +819,7 @@ dependencies = [
[[package]]
name = "client-websocket"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=3f55cea9ca386875a1668ef30600c83cd6a1ffe2#3f55cea9ca386875a1668ef30600c83cd6a1ffe2"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=d0467e7e2e8ee4b925556b5510fb6ed7322dde8c#d0467e7e2e8ee4b925556b5510fb6ed7322dde8c"
dependencies = [
"futures-channel",
"futures-util",
@ -877,7 +893,7 @@ dependencies = [
[[package]]
name = "collab"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=6febf0397e66ebf0a281980a2e7602d7af00c975#6febf0397e66ebf0a281980a2e7602d7af00c975"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=3a58d95#3a58d95a202b2814920650fa71c458fb0b49293d"
dependencies = [
"anyhow",
"async-trait",
@ -901,7 +917,7 @@ dependencies = [
[[package]]
name = "collab-database"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=6febf0397e66ebf0a281980a2e7602d7af00c975#6febf0397e66ebf0a281980a2e7602d7af00c975"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=3a58d95#3a58d95a202b2814920650fa71c458fb0b49293d"
dependencies = [
"anyhow",
"async-trait",
@ -931,7 +947,7 @@ dependencies = [
[[package]]
name = "collab-document"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=6febf0397e66ebf0a281980a2e7602d7af00c975#6febf0397e66ebf0a281980a2e7602d7af00c975"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=3a58d95#3a58d95a202b2814920650fa71c458fb0b49293d"
dependencies = [
"anyhow",
"collab",
@ -945,12 +961,13 @@ dependencies = [
"tokio",
"tokio-stream",
"tracing",
"uuid",
]
[[package]]
name = "collab-entity"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=6febf0397e66ebf0a281980a2e7602d7af00c975#6febf0397e66ebf0a281980a2e7602d7af00c975"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=3a58d95#3a58d95a202b2814920650fa71c458fb0b49293d"
dependencies = [
"anyhow",
"bytes",
@ -965,7 +982,7 @@ dependencies = [
[[package]]
name = "collab-folder"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=6febf0397e66ebf0a281980a2e7602d7af00c975#6febf0397e66ebf0a281980a2e7602d7af00c975"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=3a58d95#3a58d95a202b2814920650fa71c458fb0b49293d"
dependencies = [
"anyhow",
"chrono",
@ -1003,7 +1020,7 @@ dependencies = [
[[package]]
name = "collab-plugins"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=6febf0397e66ebf0a281980a2e7602d7af00c975#6febf0397e66ebf0a281980a2e7602d7af00c975"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=3a58d95#3a58d95a202b2814920650fa71c458fb0b49293d"
dependencies = [
"anyhow",
"async-stream",
@ -1042,7 +1059,7 @@ dependencies = [
[[package]]
name = "collab-rt-entity"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=3f55cea9ca386875a1668ef30600c83cd6a1ffe2#3f55cea9ca386875a1668ef30600c83cd6a1ffe2"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=d0467e7e2e8ee4b925556b5510fb6ed7322dde8c#d0467e7e2e8ee4b925556b5510fb6ed7322dde8c"
dependencies = [
"anyhow",
"bincode",
@ -1067,7 +1084,7 @@ dependencies = [
[[package]]
name = "collab-rt-protocol"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=3f55cea9ca386875a1668ef30600c83cd6a1ffe2#3f55cea9ca386875a1668ef30600c83cd6a1ffe2"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=d0467e7e2e8ee4b925556b5510fb6ed7322dde8c#d0467e7e2e8ee4b925556b5510fb6ed7322dde8c"
dependencies = [
"anyhow",
"async-trait",
@ -1084,7 +1101,7 @@ dependencies = [
[[package]]
name = "collab-user"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=6febf0397e66ebf0a281980a2e7602d7af00c975#6febf0397e66ebf0a281980a2e7602d7af00c975"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=3a58d95#3a58d95a202b2814920650fa71c458fb0b49293d"
dependencies = [
"anyhow",
"collab",
@ -1424,7 +1441,7 @@ checksum = "c2e66c9d817f1720209181c316d28635c050fa304f9c79e47a520882661b7308"
[[package]]
name = "database-entity"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=3f55cea9ca386875a1668ef30600c83cd6a1ffe2#3f55cea9ca386875a1668ef30600c83cd6a1ffe2"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=d0467e7e2e8ee4b925556b5510fb6ed7322dde8c#d0467e7e2e8ee4b925556b5510fb6ed7322dde8c"
dependencies = [
"anyhow",
"app-error",
@ -1816,6 +1833,7 @@ dependencies = [
name = "flowy-chat"
version = "0.1.0"
dependencies = [
"allo-isolate",
"bytes",
"dashmap",
"flowy-chat-pub",
@ -1827,6 +1845,7 @@ dependencies = [
"futures",
"lib-dispatch",
"lib-infra",
"log",
"protobuf",
"strum_macros 0.21.1",
"tokio",
@ -2834,7 +2853,7 @@ dependencies = [
[[package]]
name = "gotrue"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=3f55cea9ca386875a1668ef30600c83cd6a1ffe2#3f55cea9ca386875a1668ef30600c83cd6a1ffe2"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=d0467e7e2e8ee4b925556b5510fb6ed7322dde8c#d0467e7e2e8ee4b925556b5510fb6ed7322dde8c"
dependencies = [
"anyhow",
"futures-util",
@ -2851,7 +2870,7 @@ dependencies = [
[[package]]
name = "gotrue-entity"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=3f55cea9ca386875a1668ef30600c83cd6a1ffe2#3f55cea9ca386875a1668ef30600c83cd6a1ffe2"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=d0467e7e2e8ee4b925556b5510fb6ed7322dde8c#d0467e7e2e8ee4b925556b5510fb6ed7322dde8c"
dependencies = [
"anyhow",
"app-error",
@ -3283,7 +3302,7 @@ dependencies = [
[[package]]
name = "infra"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=3f55cea9ca386875a1668ef30600c83cd6a1ffe2#3f55cea9ca386875a1668ef30600c83cd6a1ffe2"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=d0467e7e2e8ee4b925556b5510fb6ed7322dde8c#d0467e7e2e8ee4b925556b5510fb6ed7322dde8c"
dependencies = [
"anyhow",
"reqwest",
@ -3524,11 +3543,13 @@ dependencies = [
name = "lib-infra"
version = "0.1.0"
dependencies = [
"allo-isolate",
"anyhow",
"async-trait",
"atomic_refcell",
"bytes",
"chrono",
"futures",
"futures-core",
"md5",
"pin-project",
@ -3642,9 +3663,9 @@ dependencies = [
[[package]]
name = "log"
version = "0.4.20"
version = "0.4.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f"
checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c"
[[package]]
name = "loom"
@ -5771,7 +5792,7 @@ dependencies = [
[[package]]
name = "shared-entity"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=3f55cea9ca386875a1668ef30600c83cd6a1ffe2#3f55cea9ca386875a1668ef30600c83cd6a1ffe2"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=d0467e7e2e8ee4b925556b5510fb6ed7322dde8c#d0467e7e2e8ee4b925556b5510fb6ed7322dde8c"
dependencies = [
"anyhow",
"app-error",
@ -5782,6 +5803,7 @@ dependencies = [
"database-entity",
"futures",
"gotrue-entity",
"log",
"pin-project",
"reqwest",
"serde",

View File

@ -52,7 +52,7 @@ collab-user = { version = "0.2" }
# Run the script:
# scripts/tool/update_client_api_rev.sh new_rev_id
# ⚠️⚠️⚠️️
client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "3f55cea9ca386875a1668ef30600c83cd6a1ffe2" }
client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "d0467e7e2e8ee4b925556b5510fb6ed7322dde8c" }
[dependencies]
serde_json.workspace = true
@ -106,10 +106,10 @@ default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]
[patch.crates-io]
collab = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "6febf0397e66ebf0a281980a2e7602d7af00c975" }
collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "6febf0397e66ebf0a281980a2e7602d7af00c975" }
collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "6febf0397e66ebf0a281980a2e7602d7af00c975" }
collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "6febf0397e66ebf0a281980a2e7602d7af00c975" }
collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "6febf0397e66ebf0a281980a2e7602d7af00c975" }
collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "6febf0397e66ebf0a281980a2e7602d7af00c975" }
collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "6febf0397e66ebf0a281980a2e7602d7af00c975" }
collab = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "3a58d95" }
collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "3a58d95" }
collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "3a58d95" }
collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "3a58d95" }
collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "3a58d95" }
collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "3a58d95" }
collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "3a58d95" }

View File

@ -216,7 +216,7 @@ checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da"
[[package]]
name = "app-error"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=3f55cea9ca386875a1668ef30600c83cd6a1ffe2#3f55cea9ca386875a1668ef30600c83cd6a1ffe2"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=d0467e7e2e8ee4b925556b5510fb6ed7322dde8c#d0467e7e2e8ee4b925556b5510fb6ed7322dde8c"
dependencies = [
"anyhow",
"bincode",
@ -236,7 +236,7 @@ dependencies = [
[[package]]
name = "appflowy-ai-client"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=3f55cea9ca386875a1668ef30600c83cd6a1ffe2#3f55cea9ca386875a1668ef30600c83cd6a1ffe2"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=d0467e7e2e8ee4b925556b5510fb6ed7322dde8c#d0467e7e2e8ee4b925556b5510fb6ed7322dde8c"
dependencies = [
"anyhow",
"bytes",
@ -562,7 +562,7 @@ dependencies = [
[[package]]
name = "client-api"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=3f55cea9ca386875a1668ef30600c83cd6a1ffe2#3f55cea9ca386875a1668ef30600c83cd6a1ffe2"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=d0467e7e2e8ee4b925556b5510fb6ed7322dde8c#d0467e7e2e8ee4b925556b5510fb6ed7322dde8c"
dependencies = [
"again",
"anyhow",
@ -609,7 +609,7 @@ dependencies = [
[[package]]
name = "client-websocket"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=3f55cea9ca386875a1668ef30600c83cd6a1ffe2#3f55cea9ca386875a1668ef30600c83cd6a1ffe2"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=d0467e7e2e8ee4b925556b5510fb6ed7322dde8c#d0467e7e2e8ee4b925556b5510fb6ed7322dde8c"
dependencies = [
"futures-channel",
"futures-util",
@ -787,7 +787,7 @@ dependencies = [
[[package]]
name = "collab-rt-entity"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=3f55cea9ca386875a1668ef30600c83cd6a1ffe2#3f55cea9ca386875a1668ef30600c83cd6a1ffe2"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=d0467e7e2e8ee4b925556b5510fb6ed7322dde8c#d0467e7e2e8ee4b925556b5510fb6ed7322dde8c"
dependencies = [
"anyhow",
"bincode",
@ -812,7 +812,7 @@ dependencies = [
[[package]]
name = "collab-rt-protocol"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=3f55cea9ca386875a1668ef30600c83cd6a1ffe2#3f55cea9ca386875a1668ef30600c83cd6a1ffe2"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=d0467e7e2e8ee4b925556b5510fb6ed7322dde8c#d0467e7e2e8ee4b925556b5510fb6ed7322dde8c"
dependencies = [
"anyhow",
"async-trait",
@ -1026,7 +1026,7 @@ checksum = "7e962a19be5cfc3f3bf6dd8f61eb50107f356ad6270fbb3ed41476571db78be5"
[[package]]
name = "database-entity"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=3f55cea9ca386875a1668ef30600c83cd6a1ffe2#3f55cea9ca386875a1668ef30600c83cd6a1ffe2"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=d0467e7e2e8ee4b925556b5510fb6ed7322dde8c#d0467e7e2e8ee4b925556b5510fb6ed7322dde8c"
dependencies = [
"anyhow",
"app-error",
@ -1881,7 +1881,7 @@ dependencies = [
[[package]]
name = "gotrue"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=3f55cea9ca386875a1668ef30600c83cd6a1ffe2#3f55cea9ca386875a1668ef30600c83cd6a1ffe2"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=d0467e7e2e8ee4b925556b5510fb6ed7322dde8c#d0467e7e2e8ee4b925556b5510fb6ed7322dde8c"
dependencies = [
"anyhow",
"futures-util",
@ -1898,7 +1898,7 @@ dependencies = [
[[package]]
name = "gotrue-entity"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=3f55cea9ca386875a1668ef30600c83cd6a1ffe2#3f55cea9ca386875a1668ef30600c83cd6a1ffe2"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=d0467e7e2e8ee4b925556b5510fb6ed7322dde8c#d0467e7e2e8ee4b925556b5510fb6ed7322dde8c"
dependencies = [
"anyhow",
"app-error",
@ -2199,7 +2199,7 @@ dependencies = [
[[package]]
name = "infra"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=3f55cea9ca386875a1668ef30600c83cd6a1ffe2#3f55cea9ca386875a1668ef30600c83cd6a1ffe2"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=d0467e7e2e8ee4b925556b5510fb6ed7322dde8c#d0467e7e2e8ee4b925556b5510fb6ed7322dde8c"
dependencies = [
"anyhow",
"reqwest",
@ -2339,6 +2339,7 @@ dependencies = [
"atomic_refcell",
"bytes",
"chrono",
"futures",
"futures-core",
"md5",
"pin-project",
@ -2427,9 +2428,9 @@ dependencies = [
[[package]]
name = "log"
version = "0.4.20"
version = "0.4.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f"
checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c"
[[package]]
name = "mac"
@ -3900,7 +3901,7 @@ dependencies = [
[[package]]
name = "shared-entity"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=3f55cea9ca386875a1668ef30600c83cd6a1ffe2#3f55cea9ca386875a1668ef30600c83cd6a1ffe2"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=d0467e7e2e8ee4b925556b5510fb6ed7322dde8c#d0467e7e2e8ee4b925556b5510fb6ed7322dde8c"
dependencies = [
"anyhow",
"app-error",
@ -3911,6 +3912,7 @@ dependencies = [
"database-entity",
"futures",
"gotrue-entity",
"log",
"pin-project",
"reqwest",
"serde",

View File

@ -55,7 +55,7 @@ yrs = "0.18.8"
# Run the script:
# scripts/tool/update_client_api_rev.sh new_rev_id
# ⚠️⚠️⚠️️
client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "3f55cea9ca386875a1668ef30600c83cd6a1ffe2" }
client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "d0467e7e2e8ee4b925556b5510fb6ed7322dde8c" }
@ -76,4 +76,4 @@ collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlow
collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "6febf0397e66ebf0a281980a2e7602d7af00c975" }
collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "6febf0397e66ebf0a281980a2e7602d7af00c975" }
collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "6febf0397e66ebf0a281980a2e7602d7af00c975" }
collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "6febf0397e66ebf0a281980a2e7602d7af00c975" }
collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "6febf0397e66ebf0a281980a2e7602d7af00c975" }

View File

@ -1,6 +1,6 @@
{
"all": true,
"extends": "@istanbuljs/nyc-config-typescript",
"extends": "@istanbuljs/nyc-config-babel",
"include": [
"src/**/*.ts",
"src/**/*.tsx"
@ -15,7 +15,8 @@
"text",
"html",
"text-summary",
"json"
"json",
"lcov"
],
"temp-dir": "coverage/.nyc_output",
"report-dir": "coverage/cypress"

View File

@ -1,8 +1,14 @@
FROM node:latest
FROM oven/bun:latest
WORKDIR /app
RUN apt-get update && \
apt-get install -y nginx
RUN bun install cheerio pino axios pino-pretty
COPY . .
RUN addgroup --system nginx && \
adduser --system --no-create-home --disabled-login --ingroup nginx nginx
@ -18,6 +24,11 @@ COPY nginx-signed.key /etc/ssl/private/nginx-signed.key
RUN chown -R nginx:nginx /etc/ssl/certs/nginx-signed.crt /etc/ssl/private/nginx-signed.key
COPY start.sh /app/start.sh
RUN chmod +x /app/start.sh
EXPOSE 80 443
CMD ["nginx", "-g", "daemon off;"]
CMD ["/app/start.sh"]

File diff suppressed because one or more lines are too long

View File

@ -1 +1,80 @@
[{"database_id":"037a985f-f369-4c4a-8011-620012850a68","created_at":"1713429700","views":["48c52cf7-bf98-43fa-96ad-b31aade9b071"]},{"database_id":"daea6aee-9365-4703-a8e2-a2fa6a07b214","created_at":"1714449533","views":["b6347acb-3174-4f0e-98e9-dcce07e5dbf7"]},{"database_id":"4c658817-20db-4f56-b7f9-0637a22dfeb6","created_at":"0","views":["7d2148fc-cace-4452-9c5c-96e52e6bf8b5","e410747b-5f2f-45a0-b2f7-890ad3001355","2143e95d-5dcb-4e0f-bb2c-50944e6e019f","a5566e49-f156-4168-9b2d-17926c5da329","135615fa-66f7-4451-9b54-d7e99445fca4","b4e77203-5c8b-48df-bbc5-2e1143eb0e61","a6af311f-cbc8-42c2-b801-7115619c3776"]},{"database_id":"4c658817-20db-4f56-b7f9-0637a22dfeb6","created_at":"0","views":["7d2148fc-cace-4452-9c5c-96e52e6bf8b5","e97877f5-c365-4025-9e6a-e590c4b19dbb","f0c59921-04ee-4971-995c-79b7fd8c00e2","7eb697cd-6a55-40bb-96ac-0d4a3bc924b2"]},{"database_id":"ee63da2b-aa2a-4d0b-aab0-59008635363a","created_at":"0","views":["2c1ee95a-1b09-4a1f-8d5e-501bc4861a9d","91ea7c08-f6b3-4b81-aa1e-d3664686186f"]},{"database_id":"e788f014-d0d3-4dfe-81ef-aa1ebb4d6366","created_at":"0","views":["1b0e322d-4909-4c63-914a-d034fc363097","350f425b-b671-4e2d-8182-5998a6e62924"]},{"database_id":"ad7dc45b-44b5-498f-bfa2-0f43bf05cc0d","created_at":"0","views":["0ce13415-6cce-4497-94c6-475ad96c249e","e4c89421-12b2-4d02-863d-20949eec9271"]},{"database_id":"ce267d12-3b61-4ebb-bb03-d65272f5f817","created_at":"0","views":["ee3ae8ce-959a-4df3-8734-40b535ff88e3","66a6f3bc-c78f-4f74-a09e-08d4717bf1fd","2bf50c03-f41f-4363-b5b1-101216a6c5cc"]}]
[
{
"database_id": "037a985f-f369-4c4a-8011-620012850a68",
"created_at": "1713429700",
"views": [
"48c52cf7-bf98-43fa-96ad-b31aade9b071"
]
},
{
"database_id": "daea6aee-9365-4703-a8e2-a2fa6a07b214",
"created_at": "1714449533",
"views": [
"b6347acb-3174-4f0e-98e9-dcce07e5dbf7"
]
},
{
"database_id": "4c658817-20db-4f56-b7f9-0637a22dfeb6",
"created_at": "0",
"views": [
"7d2148fc-cace-4452-9c5c-96e52e6bf8b5",
"e410747b-5f2f-45a0-b2f7-890ad3001355",
"2143e95d-5dcb-4e0f-bb2c-50944e6e019f",
"a5566e49-f156-4168-9b2d-17926c5da329",
"135615fa-66f7-4451-9b54-d7e99445fca4",
"b4e77203-5c8b-48df-bbc5-2e1143eb0e61",
"a6af311f-cbc8-42c2-b801-7115619c3776"
]
},
{
"database_id": "4c658817-20db-4f56-b7f9-0637a22dfeb6",
"created_at": "0",
"views": [
"7d2148fc-cace-4452-9c5c-96e52e6bf8b5",
"e97877f5-c365-4025-9e6a-e590c4b19dbb",
"f0c59921-04ee-4971-995c-79b7fd8c00e2",
"7eb697cd-6a55-40bb-96ac-0d4a3bc924b2"
]
},
{
"database_id": "ee63da2b-aa2a-4d0b-aab0-59008635363a",
"created_at": "0",
"views": [
"2c1ee95a-1b09-4a1f-8d5e-501bc4861a9d",
"91ea7c08-f6b3-4b81-aa1e-d3664686186f"
]
},
{
"database_id": "e788f014-d0d3-4dfe-81ef-aa1ebb4d6366",
"created_at": "0",
"views": [
"1b0e322d-4909-4c63-914a-d034fc363097",
"350f425b-b671-4e2d-8182-5998a6e62924"
]
},
{
"database_id": "ad7dc45b-44b5-498f-bfa2-0f43bf05cc0d",
"created_at": "0",
"views": [
"0ce13415-6cce-4497-94c6-475ad96c249e",
"e4c89421-12b2-4d02-863d-20949eec9271"
]
},
{
"database_id": "ce267d12-3b61-4ebb-bb03-d65272f5f817",
"created_at": "0",
"views": [
"ee3ae8ce-959a-4df3-8734-40b535ff88e3",
"66a6f3bc-c78f-4f74-a09e-08d4717bf1fd",
"2bf50c03-f41f-4363-b5b1-101216a6c5cc"
]
},
{
"database_id": "87bc006e-c1eb-47fd-9ac6-e39b17956369",
"created_at": "0",
"views": [
"7f233be4-1b4d-46b2-bcfc-f341b8d75267",
"a734a068-e73d-4b4b-853c-4daffea389c0"
]
}
]

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -36,12 +36,36 @@ declare global {
mockCurrentWorkspace: () => void;
mockGetWorkspaceDatabases: () => void;
mockDocument: (id: string) => void;
clickOutside: () => void;
getTestingSelector: (testId: string) => Chainable<JQuery<HTMLElement>>;
}
}
}
Cypress.Commands.add('mount', mount);
Cypress.Commands.add('getTestingSelector', (testId: string) => {
return cy.get(`[data-testid="${testId}"]`);
});
Cypress.Commands.add('clickOutside', () => {
cy.document().then((doc) => {
// [0, 0] is the top left corner of the window
const x = 0;
const y = 0;
const evt = new MouseEvent('click', {
bubbles: true,
cancelable: true,
view: window,
clientX: x,
clientY: y,
});
// Dispatch the event
doc.elementFromPoint(x, y)?.dispatchEvent(evt);
});
});
// Example use:
// cy.mount(<MyComponent />)

View File

@ -4,8 +4,28 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/appflowy.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AppFlowy</title>
<meta name="description"
content="AppFlowy is an AI collaborative workspace where you achieve more without losing control of your data"
/>
<meta property="og:title" content="AppFlowy" />
<meta property="og:description"
content="AppFlowy is an AI collaborative workspace where you achieve more without losing control of your data"
/>
<meta property="og:image"
content="https://d3uafhn8yrvdfn.cloudfront.net/website/production/_next/static/media/og-image.e347bfb5.png"
/>
<meta property="og:url" content="https://appflowy.com" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="AppFlowy" />
<meta name="twitter:description"
content="AppFlowy is an AI collaborative workspace where you achieve more without losing control of your data"
/>
<meta name="twitter:image"
content="https://d3uafhn8yrvdfn.cloudfront.net/website/production/_next/static/media/og-image.e347bfb5.png"
/>
<meta name="twitter:site" content="@appflowy" />
<meta name="twitter:creator" content="@appflowy" />
</head>
<body id="body">
<div id="root"></div>

View File

@ -5,7 +5,7 @@ const esModules = ['lodash-es', 'nanoid'].join('|');
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testEnvironment: 'jsdom',
roots: ['<rootDir>'],
modulePaths: [compilerOptions.baseUrl],
moduleNameMapper: {
@ -14,10 +14,28 @@ module.exports = {
'^nanoid(/(.*)|$)': 'nanoid$1',
},
'transform': {
'^.+\\.(j|t)sx?$': 'ts-jest',
'(.*)/node_modules/nanoid/.+\\.(j|t)sx?$': 'ts-jest',
},
'transformIgnorePatterns': [`/node_modules/(?!${esModules})`],
testMatch: ['**/*.test.ts'],
testMatch: ['**/*.test.ts', '**/*.test.tsx'],
coverageDirectory: '<rootDir>/coverage/jest',
collectCoverage: true,
coverageProvider: 'v8',
coveragePathIgnorePatterns: [
'/cypress/',
'/coverage/',
'/node_modules/',
'/__tests__/',
'/__mocks__/',
'/__fixtures__/',
'/__helpers__/',
'/__utils__/',
'/__constants__/',
'/__types__/',
'/__mocks__/',
'/__stubs__/',
'/__fixtures__/',
'/application/folder-yjs/',
],
};

View File

@ -30,6 +30,10 @@ http {
gzip_http_version 1.0;
gzip_comp_level 5;
gzip_vary on;
gzip_types text/plain text/css application/json application/x-javascript text/xml application/xml application/xml+rss text/javascript application/javascript application/wasm;
# Existing server block for HTTP
@ -61,15 +65,22 @@ http {
ssl_prefer_server_ciphers on;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
location /static/ {
root /usr/share/nginx/html;
expires 30d;
access_log off;
location ~* \.wasm$ {
types { application/wasm wasm; }
default_type application/wasm;
}
}
location /appflowy.svg {

View File

@ -21,8 +21,7 @@
"test:components": "cypress run --component --browser chrome --headless",
"test:unit": "jest --coverage",
"test:cy": "cypress run",
"merge-coverage": "node scripts/merge-coverage.cjs",
"coverage": "pnpm run test:unit && pnpm run test:components && pnpm run merge-coverage"
"coverage": "pnpm run test:unit && pnpm run test:components"
},
"dependencies": {
"@appflowyinc/client-api-wasm": "0.0.3",
@ -99,11 +98,15 @@
"yjs": "^13.6.14"
},
"devDependencies": {
"@babel/preset-env": "^7.24.7",
"@babel/preset-react": "^7.24.7",
"@babel/preset-typescript": "^7.24.7",
"@cypress/code-coverage": "^3.12.39",
"@istanbuljs/nyc-config-babel": "^3.0.0",
"@istanbuljs/nyc-config-typescript": "^1.0.2",
"@svgr/plugin-svgo": "^8.0.1",
"@tauri-apps/cli": "^1.5.11",
"@testing-library/react": "^16.0.0",
"@types/google-protobuf": "^3.15.12",
"@types/is-hotkey": "^0.1.7",
"@types/jest": "^29.5.3",

File diff suppressed because it is too large Load Diff

View File

@ -1,31 +0,0 @@
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const jestCoverageFile = path.join(__dirname, '../coverage/jest/coverage-final.json');
const cypressCoverageFile = path.join(__dirname, '../coverage/cypress/coverage-final.json');
// const cypressComponentCoverageFile = path.join(__dirname, '../coverage/cypress-component/coverage-final.json');
const nycOutputDir = path.join(__dirname, '../coverage/.nyc_output');
// Ensure .nyc_output directory exists
if (!fs.existsSync(nycOutputDir)) {
fs.mkdirSync(nycOutputDir, { recursive: true });
}
// Copy Jest coverage file
fs.copyFileSync(jestCoverageFile, path.join(nycOutputDir, 'jest-coverage.json'));
// Copy Cypress E2E coverage file
fs.copyFileSync(cypressCoverageFile, path.join(nycOutputDir, 'cypress-coverage.json'));
// Copy Cypress Component coverage file
// fs.copyFileSync(cypressComponentCoverageFile, path.join(nycOutputDir, 'cypress-component-coverage.json'));
// Merge coverage files
execSync('nyc merge ./coverage/.nyc_output ./coverage/merged/coverage-final.json', { stdio: 'inherit' });
// Generate final merged report
execSync('nyc report --reporter=html --reporter=text-summary --report-dir=coverage/merged --temp-dir=coverage/.nyc_output', { stdio: 'inherit' });
console.log(`Merged coverage report written to coverage/merged`);
const GITHUB_STEP_SUMMARY = process.env.GITHUB_STEP_SUMMARY;
if (GITHUB_STEP_SUMMARY) {
const coverageSummary = execSync('nyc report --reporter=html --reporter=text-summary --report-dir=coverage/merged --temp-dir=coverage/.nyc_output').toString();
fs.appendFileSync(GITHUB_STEP_SUMMARY, `### Coverage Report\n\`\`\`\n${coverageSummary}\n\`\`\`\n`);
}

View File

@ -0,0 +1,114 @@
const path = require('path');
const fs = require('fs');
const pino = require('pino');
const cheerio = require('cheerio');
const axios = require('axios');
const distDir = path.join(__dirname, 'dist');
const indexPath = path.join(distDir, 'index.html');
const setOrUpdateMetaTag = ($, selector, attribute, content) => {
if ($(selector).length === 0) {
$('head').append(`<meta ${attribute}="${selector.match(/\[(.*?)\]/)[1]}" content="${content}">`);
} else {
$(selector).attr('content', content);
}
};
// Create a new logger instance
const logger = pino({
transport: {
target: 'pino-pretty',
level: 'info',
options: {
colorize: true,
translateTime: 'SYS:standard',
destination: `${__dirname}/pino-logger.log`,
},
},
});
const logRequestTimer = (req) => {
const start = Date.now();
const pathname = new URL(req.url).pathname;
logger.info(`Incoming request: ${pathname}`);
return () => {
const duration = Date.now() - start;
logger.info(`Request for ${pathname} took ${duration}ms`);
};
};
const fetchMetaData = async (url) => {
try {
const response = await axios.get(url);
return response.data;
} catch (error) {
logger.error('Error fetching meta data', error);
return null;
}
};
const createServer = async (req) => {
const timer = logRequestTimer(req);
if (req.method === 'GET') {
const pageId = req.url.split('/').pop();
let htmlData = fs.readFileSync(indexPath, 'utf8');
const $ = cheerio.load(htmlData);
if (!pageId) {
timer();
return new Response($.html(), {
headers: { 'Content-Type': 'text/html' },
});
}
const description = 'Write, share, comment, react, and publish docs quickly and securely on AppFlowy.';
let title = 'AppFlowy';
const url = 'https://appflowy.com';
let image = 'https://d3uafhn8yrvdfn.cloudfront.net/website/production/_next/static/media/og-image.e347bfb5.png';
// Inject meta data into the HTML to support SEO and social sharing
// if (metaData) {
// title = metaData.title;
// image = metaData.image;
// }
$('title').text(title);
setOrUpdateMetaTag($, 'meta[name="description"]', 'name', description);
setOrUpdateMetaTag($, 'meta[property="og:title"]', 'property', title);
setOrUpdateMetaTag($, 'meta[property="og:description"]', 'property', description);
setOrUpdateMetaTag($, 'meta[property="og:image"]', 'property', image);
setOrUpdateMetaTag($, 'meta[property="og:url"]', 'property', url);
setOrUpdateMetaTag($, 'meta[property="og:type"]', 'property', 'article');
setOrUpdateMetaTag($, 'meta[name="twitter:card"]', 'name', 'summary_large_image');
setOrUpdateMetaTag($, 'meta[name="twitter:title"]', 'name', title);
setOrUpdateMetaTag($, 'meta[name="twitter:description"]', 'name', description);
setOrUpdateMetaTag($, 'meta[name="twitter:image"]', 'name', image);
timer();
return new Response($.html(), {
headers: { 'Content-Type': 'text/html' },
});
} else {
timer();
logger.error({ message: 'Method not allowed', method: req.method });
return new Response('Method not allowed', { status: 405 });
}
};
const start = () => {
try {
Bun.serve({
port: 3000,
fetch: createServer,
error: (err) => {
logger.error(`Internal Server Error: ${err}`);
return new Response('Internal Server Error', { status: 500 });
},
});
logger.info(`Server is running on port 3000`);
} catch (err) {
logger.error(err);
process.exit(1);
}
};
start();

View File

@ -108,6 +108,16 @@ dependencies = [
"memchr",
]
[[package]]
name = "allo-isolate"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97b6d794345b06592d0ebeed8e477e41b71e5a0a49df4fc0e4184d5938b99509"
dependencies = [
"atomic",
"pin-project",
]
[[package]]
name = "alloc-no-stdlib"
version = "2.0.4"
@ -153,7 +163,7 @@ checksum = "0952808a6c2afd1aa8947271f3a60f1a6763c7b912d210184c5149b5cf147247"
[[package]]
name = "app-error"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=3f55cea9ca386875a1668ef30600c83cd6a1ffe2#3f55cea9ca386875a1668ef30600c83cd6a1ffe2"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=d0467e7e2e8ee4b925556b5510fb6ed7322dde8c#d0467e7e2e8ee4b925556b5510fb6ed7322dde8c"
dependencies = [
"anyhow",
"bincode",
@ -173,7 +183,7 @@ dependencies = [
[[package]]
name = "appflowy-ai-client"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=3f55cea9ca386875a1668ef30600c83cd6a1ffe2#3f55cea9ca386875a1668ef30600c83cd6a1ffe2"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=d0467e7e2e8ee4b925556b5510fb6ed7322dde8c#d0467e7e2e8ee4b925556b5510fb6ed7322dde8c"
dependencies = [
"anyhow",
"bytes",
@ -299,6 +309,12 @@ dependencies = [
"system-deps 6.2.2",
]
[[package]]
name = "atomic"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba"
[[package]]
name = "atomic_refcell"
version = "0.1.13"
@ -730,7 +746,7 @@ dependencies = [
[[package]]
name = "client-api"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=3f55cea9ca386875a1668ef30600c83cd6a1ffe2#3f55cea9ca386875a1668ef30600c83cd6a1ffe2"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=d0467e7e2e8ee4b925556b5510fb6ed7322dde8c#d0467e7e2e8ee4b925556b5510fb6ed7322dde8c"
dependencies = [
"again",
"anyhow",
@ -777,7 +793,7 @@ dependencies = [
[[package]]
name = "client-websocket"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=3f55cea9ca386875a1668ef30600c83cd6a1ffe2#3f55cea9ca386875a1668ef30600c83cd6a1ffe2"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=d0467e7e2e8ee4b925556b5510fb6ed7322dde8c#d0467e7e2e8ee4b925556b5510fb6ed7322dde8c"
dependencies = [
"futures-channel",
"futures-util",
@ -860,7 +876,7 @@ dependencies = [
[[package]]
name = "collab"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=6febf0397e66ebf0a281980a2e7602d7af00c975#6febf0397e66ebf0a281980a2e7602d7af00c975"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=3a58d95#3a58d95a202b2814920650fa71c458fb0b49293d"
dependencies = [
"anyhow",
"async-trait",
@ -884,7 +900,7 @@ dependencies = [
[[package]]
name = "collab-database"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=6febf0397e66ebf0a281980a2e7602d7af00c975#6febf0397e66ebf0a281980a2e7602d7af00c975"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=3a58d95#3a58d95a202b2814920650fa71c458fb0b49293d"
dependencies = [
"anyhow",
"async-trait",
@ -914,7 +930,7 @@ dependencies = [
[[package]]
name = "collab-document"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=6febf0397e66ebf0a281980a2e7602d7af00c975#6febf0397e66ebf0a281980a2e7602d7af00c975"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=3a58d95#3a58d95a202b2814920650fa71c458fb0b49293d"
dependencies = [
"anyhow",
"collab",
@ -928,12 +944,13 @@ dependencies = [
"tokio",
"tokio-stream",
"tracing",
"uuid",
]
[[package]]
name = "collab-entity"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=6febf0397e66ebf0a281980a2e7602d7af00c975#6febf0397e66ebf0a281980a2e7602d7af00c975"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=3a58d95#3a58d95a202b2814920650fa71c458fb0b49293d"
dependencies = [
"anyhow",
"bytes",
@ -948,7 +965,7 @@ dependencies = [
[[package]]
name = "collab-folder"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=6febf0397e66ebf0a281980a2e7602d7af00c975#6febf0397e66ebf0a281980a2e7602d7af00c975"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=3a58d95#3a58d95a202b2814920650fa71c458fb0b49293d"
dependencies = [
"anyhow",
"chrono",
@ -986,7 +1003,7 @@ dependencies = [
[[package]]
name = "collab-plugins"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=6febf0397e66ebf0a281980a2e7602d7af00c975#6febf0397e66ebf0a281980a2e7602d7af00c975"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=3a58d95#3a58d95a202b2814920650fa71c458fb0b49293d"
dependencies = [
"anyhow",
"async-stream",
@ -1025,7 +1042,7 @@ dependencies = [
[[package]]
name = "collab-rt-entity"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=3f55cea9ca386875a1668ef30600c83cd6a1ffe2#3f55cea9ca386875a1668ef30600c83cd6a1ffe2"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=d0467e7e2e8ee4b925556b5510fb6ed7322dde8c#d0467e7e2e8ee4b925556b5510fb6ed7322dde8c"
dependencies = [
"anyhow",
"bincode",
@ -1050,7 +1067,7 @@ dependencies = [
[[package]]
name = "collab-rt-protocol"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=3f55cea9ca386875a1668ef30600c83cd6a1ffe2#3f55cea9ca386875a1668ef30600c83cd6a1ffe2"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=d0467e7e2e8ee4b925556b5510fb6ed7322dde8c#d0467e7e2e8ee4b925556b5510fb6ed7322dde8c"
dependencies = [
"anyhow",
"async-trait",
@ -1067,7 +1084,7 @@ dependencies = [
[[package]]
name = "collab-user"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=6febf0397e66ebf0a281980a2e7602d7af00c975#6febf0397e66ebf0a281980a2e7602d7af00c975"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=3a58d95#3a58d95a202b2814920650fa71c458fb0b49293d"
dependencies = [
"anyhow",
"collab",
@ -1411,7 +1428,7 @@ checksum = "7e962a19be5cfc3f3bf6dd8f61eb50107f356ad6270fbb3ed41476571db78be5"
[[package]]
name = "database-entity"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=3f55cea9ca386875a1668ef30600c83cd6a1ffe2#3f55cea9ca386875a1668ef30600c83cd6a1ffe2"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=d0467e7e2e8ee4b925556b5510fb6ed7322dde8c#d0467e7e2e8ee4b925556b5510fb6ed7322dde8c"
dependencies = [
"anyhow",
"app-error",
@ -1853,6 +1870,7 @@ dependencies = [
name = "flowy-chat"
version = "0.1.0"
dependencies = [
"allo-isolate",
"bytes",
"dashmap",
"flowy-chat-pub",
@ -1864,6 +1882,7 @@ dependencies = [
"futures",
"lib-dispatch",
"lib-infra",
"log",
"protobuf",
"strum_macros 0.21.1",
"tokio",
@ -2173,6 +2192,7 @@ dependencies = [
"nanoid",
"parking_lot 0.12.1",
"protobuf",
"serde",
"serde_json",
"strum_macros 0.21.1",
"tokio",
@ -2907,7 +2927,7 @@ dependencies = [
[[package]]
name = "gotrue"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=3f55cea9ca386875a1668ef30600c83cd6a1ffe2#3f55cea9ca386875a1668ef30600c83cd6a1ffe2"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=d0467e7e2e8ee4b925556b5510fb6ed7322dde8c#d0467e7e2e8ee4b925556b5510fb6ed7322dde8c"
dependencies = [
"anyhow",
"futures-util",
@ -2924,7 +2944,7 @@ dependencies = [
[[package]]
name = "gotrue-entity"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=3f55cea9ca386875a1668ef30600c83cd6a1ffe2#3f55cea9ca386875a1668ef30600c83cd6a1ffe2"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=d0467e7e2e8ee4b925556b5510fb6ed7322dde8c#d0467e7e2e8ee4b925556b5510fb6ed7322dde8c"
dependencies = [
"anyhow",
"app-error",
@ -3361,7 +3381,7 @@ dependencies = [
[[package]]
name = "infra"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=3f55cea9ca386875a1668ef30600c83cd6a1ffe2#3f55cea9ca386875a1668ef30600c83cd6a1ffe2"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=d0467e7e2e8ee4b925556b5510fb6ed7322dde8c#d0467e7e2e8ee4b925556b5510fb6ed7322dde8c"
dependencies = [
"anyhow",
"reqwest",
@ -3607,11 +3627,13 @@ dependencies = [
name = "lib-infra"
version = "0.1.0"
dependencies = [
"allo-isolate",
"anyhow",
"async-trait",
"atomic_refcell",
"bytes",
"chrono",
"futures",
"futures-core",
"md5",
"pin-project",
@ -5865,7 +5887,7 @@ dependencies = [
[[package]]
name = "shared-entity"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=3f55cea9ca386875a1668ef30600c83cd6a1ffe2#3f55cea9ca386875a1668ef30600c83cd6a1ffe2"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=d0467e7e2e8ee4b925556b5510fb6ed7322dde8c#d0467e7e2e8ee4b925556b5510fb6ed7322dde8c"
dependencies = [
"anyhow",
"app-error",
@ -5876,6 +5898,7 @@ dependencies = [
"database-entity",
"futures",
"gotrue-entity",
"log",
"pin-project",
"reqwest",
"serde",

View File

@ -52,7 +52,7 @@ collab-user = { version = "0.2" }
# Run the script:
# scripts/tool/update_client_api_rev.sh new_rev_id
# ⚠️⚠️⚠️️
client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "3f55cea9ca386875a1668ef30600c83cd6a1ffe2" }
client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "d0467e7e2e8ee4b925556b5510fb6ed7322dde8c" }
[dependencies]
serde_json.workspace = true
@ -107,10 +107,10 @@ default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]
[patch.crates-io]
collab = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "6febf0397e66ebf0a281980a2e7602d7af00c975" }
collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "6febf0397e66ebf0a281980a2e7602d7af00c975" }
collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "6febf0397e66ebf0a281980a2e7602d7af00c975" }
collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "6febf0397e66ebf0a281980a2e7602d7af00c975" }
collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "6febf0397e66ebf0a281980a2e7602d7af00c975" }
collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "6febf0397e66ebf0a281980a2e7602d7af00c975" }
collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "6febf0397e66ebf0a281980a2e7602d7af00c975" }
collab = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "3a58d95" }
collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "3a58d95" }
collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "3a58d95" }
collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "3a58d95" }
collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "3a58d95" }
collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "3a58d95" }
collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "3a58d95" }

View File

@ -369,7 +369,19 @@ export interface YDocument extends Y.Map<unknown> {
}
export interface YBlocks extends Y.Map<unknown> {
get(key: BlockId): Y.Map<unknown>;
get(key: BlockId): YBlock;
}
export interface YBlock extends Y.Map<unknown> {
get(key: YjsEditorKey.block_id | YjsEditorKey.block_parent): BlockId;
get(key: YjsEditorKey.block_type): BlockType;
get(key: YjsEditorKey.block_data): string;
get(key: YjsEditorKey.block_children): ChildrenId;
get(key: YjsEditorKey.block_external_id): ExternalId;
}
export interface YMeta extends Y.Map<unknown> {

View File

@ -27,6 +27,7 @@ import {
filterBy,
} from '../filter';
import { expect } from '@jest/globals';
import * as Y from 'yjs';
describe('Text filter check', () => {
const text = 'Hello, world!';
@ -540,6 +541,15 @@ describe('Database filterBy', () => {
expect(result).toBe('1,2,3,4,5,6,7,8,9,10');
});
it('should return all rows for empty rowMap', () => {
const { filters, fields } = withTestingData();
const rowMap = new Y.Map() as Y.Map<Y.Doc>;
const result = filterBy(rows, filters, fields, rowMap)
.map((row) => row.id)
.join(',');
expect(result).toBe('1,2,3,4,5,6,7,8,9,10');
});
it('should return rows that match text filter', () => {
const { filters, fields, rowMap } = withTestingData();
const filter = withRichTextFilter();

View File

@ -78,5 +78,25 @@
"field_id": "url_field",
"condition": "desc",
"id": "sort_desc_url_field"
},
"sort_asc_created_at": {
"field_id": "created_at_field",
"condition": "asc",
"id": "sort_asc_created_at"
},
"sort_desc_created_at": {
"field_id": "created_at_field",
"condition": "desc",
"id": "sort_desc_created_at"
},
"sort_asc_updated_at": {
"field_id": "last_modified_field",
"condition": "asc",
"id": "sort_asc_updated_at"
},
"sort_desc_updated_at": {
"field_id": "last_modified_field",
"condition": "desc",
"id": "sort_desc_updated_at"
}
}

View File

@ -1,8 +1,17 @@
import { Row } from '@/application/database-yjs';
import { FieldType, Row } from '@/application/database-yjs';
import { withTestingData } from '@/application/database-yjs/__tests__/withTestingData';
import { withTestingRows } from '@/application/database-yjs/__tests__/withTestingRows';
import { expect } from '@jest/globals';
import { groupByField } from '../group';
import * as Y from 'yjs';
import {
YDatabaseField,
YDatabaseFieldTypeOption,
YjsDatabaseKey,
YjsEditorKey,
YMapFieldTypeOption,
} from '@/application/collab.type';
import { YjsEditor } from '@/application/slate-yjs';
describe('Database group', () => {
let rows: Row[];
@ -95,4 +104,69 @@ describe('Database group', () => {
]);
expect(result).toEqual(expectRes);
});
it('should not group if no options', () => {
const { fields, rowMap } = withTestingData();
const field = new Y.Map() as YDatabaseField;
const typeOption = new Y.Map() as YDatabaseFieldTypeOption;
const now = Date.now().toString();
field.set(YjsDatabaseKey.name, 'Single Select Field');
field.set(YjsDatabaseKey.id, 'another_single_select_field');
field.set(YjsDatabaseKey.type, String(FieldType.SingleSelect));
field.set(YjsDatabaseKey.last_modified, now.valueOf());
field.set(YjsDatabaseKey.type_option, typeOption);
fields.set('another_single_select_field', field);
expect(groupByField(rows, rowMap, field)).toBeUndefined();
const selectTypeOption = new Y.Map() as YMapFieldTypeOption;
typeOption.set(String(FieldType.SingleSelect), selectTypeOption);
selectTypeOption.set(YjsDatabaseKey.content, JSON.stringify({ disable_color: false, options: [] }));
const expectRes = new Map([['another_single_select_field', rows]]);
expect(groupByField(rows, rowMap, field)).toEqual(expectRes);
});
it('should handle empty selected ids', () => {
const { fields, rowMap } = withTestingData();
const cell = rowMap
.get('1')
?.getMap(YjsEditorKey.data_section)
?.get(YjsEditorKey.database_row)
?.get(YjsDatabaseKey.cells)
?.get('single_select_field');
cell?.set(YjsDatabaseKey.data, null);
const field = fields.get('single_select_field');
const result = groupByField(rows, rowMap, field);
expect(result).toEqual(
new Map([
['single_select_field', [{ id: '1', height: 37 }]],
[
'2',
[
{ id: '2', height: 37 },
{ id: '5', height: 37 },
{ id: '8', height: 37 },
],
],
[
'3',
[
{ id: '3', height: 37 },
{ id: '6', height: 37 },
{ id: '9', height: 37 },
],
],
[
'1',
[
{ id: '4', height: 37 },
{ id: '7', height: 37 },
{ id: '10', height: 37 },
],
],
])
);
});
});

View File

@ -0,0 +1,72 @@
import { parseYDatabaseCellToCell } from '@/application/database-yjs/cell.parse';
import { expect } from '@jest/globals';
import { withTestingCheckboxCell, withTestingDateCell } from '@/application/database-yjs/__tests__/withTestingCell';
import * as Y from 'yjs';
import {
FieldType,
parseSelectOptionTypeOptions,
parseRelationTypeOption,
parseNumberTypeOptions,
} from '@/application/database-yjs';
import { YDatabaseField, YDatabaseFieldTypeOption, YjsDatabaseKey } from '@/application/collab.type';
import { withNumberTestingField, withRelationTestingField } from '@/application/database-yjs/__tests__/withTestingField';
describe('parseYDatabaseCellToCell', () => {
it('should parse a DateTime cell', () => {
const doc = new Y.Doc();
const cell = withTestingDateCell();
doc.getMap('cells').set('date_field', cell);
const parsedCell = parseYDatabaseCellToCell(cell);
expect(parsedCell.data).not.toBe(undefined);
expect(parsedCell.createdAt).not.toBe(undefined);
expect(parsedCell.lastModified).not.toBe(undefined);
expect(parsedCell.fieldType).toBe(Number(FieldType.DateTime));
});
it('should parse a Checkbox cell', () => {
const doc = new Y.Doc();
const cell = withTestingCheckboxCell();
doc.getMap('cells').set('checkbox_field', cell);
const parsedCell = parseYDatabaseCellToCell(cell);
expect(parsedCell.data).toBe(true);
expect(parsedCell.createdAt).not.toBe(undefined);
expect(parsedCell.lastModified).not.toBe(undefined);
expect(parsedCell.fieldType).toBe(Number(FieldType.Checkbox));
});
});
describe('Select option field parse', () => {
it('should parse select option type options', () => {
const doc = new Y.Doc();
const field = new Y.Map() as YDatabaseField;
const typeOption = new Y.Map() as YDatabaseFieldTypeOption;
const now = Date.now().toString();
field.set(YjsDatabaseKey.name, 'Single Select Field');
field.set(YjsDatabaseKey.id, 'single_select_field');
field.set(YjsDatabaseKey.type, String(FieldType.SingleSelect));
field.set(YjsDatabaseKey.last_modified, now.valueOf());
field.set(YjsDatabaseKey.type_option, typeOption);
doc.getMap('fields').set('single_select_field', field);
expect(parseSelectOptionTypeOptions(field)).toEqual(null);
});
});
describe('number field parse', () => {
it('should parse number field', () => {
const doc = new Y.Doc();
const field = withNumberTestingField();
doc.getMap('fields').set('number_field', field);
expect(parseNumberTypeOptions(field)).toEqual({
format: 0,
});
});
});
describe('relation field parse', () => {
it('should parse relation field', () => {
const doc = new Y.Doc();
const field = withRelationTestingField();
doc.getMap('fields').set('relation_field', field);
expect(parseRelationTypeOption(field)).toEqual(undefined);
});
});

View File

@ -0,0 +1,283 @@
import { renderHook } from '@testing-library/react';
import {
useCalendarEventsSelector,
useCellSelector,
useFieldSelector,
useFieldsSelector,
useFilterSelector,
useFiltersSelector,
useGroup,
useGroupsSelector,
usePrimaryFieldId,
useRowDataSelector,
useRowDocMapSelector,
useRowMetaSelector,
useRowOrdersSelector,
useRowsByGroup,
useSortSelector,
useSortsSelector,
} from '../selector';
import { useDatabaseViewId } from '../context';
import { IdProvider } from '@/components/_shared/context-provider/IdProvider';
import { DatabaseContextProvider } from '@/components/database/DatabaseContext';
import { withTestingDatabase } from '@/application/database-yjs/__tests__/withTestingData';
import { expect } from '@jest/globals';
import { YDoc, YjsDatabaseKey, YjsEditorKey, YSharedRoot } from '@/application/collab.type';
import * as Y from 'yjs';
import { withNumberTestingField, withTestingFields } from '@/application/database-yjs/__tests__/withTestingField';
import { withTestingRows } from '@/application/database-yjs/__tests__/withTestingRows';
const wrapperCreator =
(viewId: string, doc: YDoc, rowDocMap: Y.Map<YDoc>) =>
({ children }: { children: React.ReactNode }) => {
return (
<IdProvider objectId={viewId}>
<DatabaseContextProvider viewId={viewId} databaseDoc={doc} rowDocMap={rowDocMap} readOnly={true}>
{children}
</DatabaseContextProvider>
</IdProvider>
);
};
describe('Database selector', () => {
let wrapper: ({ children }: { children: React.ReactNode }) => JSX.Element;
let rowDocMap: Y.Map<YDoc>;
let doc: YDoc;
beforeEach(() => {
const data = withTestingDatabase('1');
doc = data.doc;
rowDocMap = data.rowDocMap;
wrapper = wrapperCreator('1', doc, rowDocMap);
});
it('should select a field', () => {
const { result } = renderHook(() => useFieldSelector('number_field'), { wrapper });
const tempDoc = new Y.Doc();
const field = withNumberTestingField();
tempDoc.getMap().set('number_field', field);
expect(result.current.field?.toJSON()).toEqual(field.toJSON());
});
it('should select all fields', () => {
const { result } = renderHook(() => useFieldsSelector(), { wrapper });
expect(result.current.map((item) => item.fieldId)).toEqual(Array.from(withTestingFields().keys()));
});
it('should select all filters', () => {
const { result } = renderHook(() => useFiltersSelector(), { wrapper });
expect(result.current).toEqual(['filter_multi_select_field']);
});
it('should select a filter', () => {
const { result } = renderHook(() => useFilterSelector('filter_multi_select_field'), { wrapper });
expect(result.current).toEqual({
content: '1,3',
condition: 2,
fieldId: 'multi_select_field',
id: 'filter_multi_select_field',
filterType: NaN,
optionIds: ['1', '3'],
});
});
it('should select all sorts', () => {
const { result } = renderHook(() => useSortsSelector(), { wrapper });
expect(result.current).toEqual(['sort_asc_text_field']);
});
it('should select a sort', () => {
const { result } = renderHook(() => useSortSelector('sort_asc_text_field'), { wrapper });
expect(result.current).toEqual({
fieldId: 'text_field',
id: 'sort_asc_text_field',
condition: 0,
});
});
it('should select all groups', () => {
const { result } = renderHook(() => useGroupsSelector(), { wrapper });
expect(result.current).toEqual(['g:single_select_field']);
});
it('should select a group', () => {
const { result } = renderHook(() => useGroup('g:single_select_field'), { wrapper });
expect(result.current).toEqual({
fieldId: 'single_select_field',
columns: [
{
id: '1',
visible: true,
},
{
id: 'single_select_field',
visible: true,
},
],
});
});
it('should select rows by group', () => {
const { result } = renderHook(() => useRowsByGroup('g:single_select_field'), { wrapper });
const { fieldId, columns, notFound, groupResult } = result.current;
expect(fieldId).toEqual('single_select_field');
expect(columns).toEqual([
{
id: '1',
visible: true,
},
{
id: 'single_select_field',
visible: true,
},
]);
expect(notFound).toBeFalsy();
expect(groupResult).toEqual(
new Map([
[
'1',
[
{ id: '1', height: 37 },
{ id: '7', height: 37 },
],
],
[
'2',
[
{ id: '2', height: 37 },
{ id: '8', height: 37 },
{ id: '5', height: 37 },
],
],
[
'3',
[
{ id: '9', height: 37 },
{ id: '3', height: 37 },
{ id: '6', height: 37 },
],
],
])
);
});
it('should select all row orders', () => {
const { result } = renderHook(() => useRowOrdersSelector(), { wrapper });
expect(result.current?.map((item) => item.id).join(',')).toEqual('9,2,3,1,6,8,5,7');
});
it('should select all row doc map', () => {
const { result } = renderHook(() => useRowDocMapSelector(), { wrapper });
expect(result.current.rows).toEqual(rowDocMap);
});
it('should select a row data', () => {
const rows = withTestingRows();
const { result } = renderHook(() => useRowDataSelector(rows[0].id), { wrapper });
expect(result.current.row.toJSON()).toEqual(
rowDocMap.get(rows[0].id)?.getMap(YjsEditorKey.data_section)?.get(YjsEditorKey.database_row)?.toJSON()
);
});
it('should select a cell', () => {
const rows = withTestingRows();
const { result } = renderHook(
() =>
useCellSelector({
rowId: rows[0].id,
fieldId: 'number_field',
}),
{ wrapper }
);
expect(result.current).toEqual({
createdAt: NaN,
data: 123,
fieldType: 1,
lastModified: NaN,
});
});
it('should select a primary field id', () => {
const { result } = renderHook(() => usePrimaryFieldId(), { wrapper });
expect(result.current).toEqual('text_field');
});
it('should select a row meta', () => {
const rows = withTestingRows();
const { result } = renderHook(() => useRowMetaSelector(rows[0].id), { wrapper });
expect(result.current?.documentId).not.toBeNull();
});
it('should select all calendar events', () => {
const { result } = renderHook(() => useCalendarEventsSelector(), { wrapper });
expect(result.current.events.length).toEqual(8);
expect(result.current.emptyEvents.length).toEqual(0);
});
it('should select view id', () => {
const { result } = renderHook(() => useDatabaseViewId(), { wrapper });
expect(result.current).toEqual('1');
});
it('should select all rows if filter is not found', () => {
const view = (doc.get(YjsEditorKey.data_section) as YSharedRoot)
.get(YjsEditorKey.database)
.get(YjsDatabaseKey.views)
.get('1');
view.set(YjsDatabaseKey.filters, new Y.Array());
const { result } = renderHook(() => useRowOrdersSelector(), { wrapper });
expect(result.current?.map((item) => item.id).join(',')).toEqual('9,2,3,4,1,6,10,8,5,7');
});
it('should select original row orders if sorts is not found', () => {
const view = (doc.get(YjsEditorKey.data_section) as YSharedRoot)
.get(YjsEditorKey.database)
.get(YjsDatabaseKey.views)
.get('1');
view.set(YjsDatabaseKey.sorts, new Y.Array());
const { result } = renderHook(() => useRowOrdersSelector(), { wrapper });
expect(result.current?.map((item) => item.id).join(',')).toEqual('1,2,3,5,6,7,8,9');
});
it('should select all rows if filters and sorts are not found', () => {
const view = (doc.get(YjsEditorKey.data_section) as YSharedRoot)
.get(YjsEditorKey.database)
.get(YjsDatabaseKey.views)
.get('1');
view.set(YjsDatabaseKey.filters, new Y.Array());
view.set(YjsDatabaseKey.sorts, new Y.Array());
const { result } = renderHook(() => useRowOrdersSelector(), { wrapper });
expect(result.current?.map((item) => item.id).join(',')).toEqual('1,2,3,4,5,6,7,8,9,10');
});
});

View File

@ -4,7 +4,9 @@ import { withTestingRows } from '@/application/database-yjs/__tests__/withTestin
import {
withCheckboxSort,
withChecklistSort,
withCreatedAtSort,
withDateTimeSort,
withLastModifiedSort,
withMultiSelectOptionSort,
withNumberSort,
withRichTextSort,
@ -19,10 +21,12 @@ import {
withSelectOptionTestingField,
withURLTestingField,
withChecklistTestingField,
withRelationTestingField,
} from './withTestingField';
import { sortBy, parseCellDataForSort } from '../sort';
import * as Y from 'yjs';
import { expect } from '@jest/globals';
import { YjsDatabaseKey, YjsEditorKey } from '@/application/collab.type';
describe('parseCellDataForSort', () => {
it('should parse data correctly based on field type', () => {
@ -127,6 +131,17 @@ describe('parseCellDataForSort', () => {
expect(result).toBe(0);
});
it('should return empty string for Relation field', () => {
const doc = new Y.Doc();
const field = withRelationTestingField();
doc.getMap().set('field', field);
const data = '';
const result = parseCellDataForSort(field, data);
expect(result).toBe('');
});
});
describe('Database sortBy', () => {
@ -136,6 +151,53 @@ describe('Database sortBy', () => {
rows = withTestingRows();
});
it('should not sort rows if no sort is provided', () => {
const { sorts, fields, rowMap } = withTestingData();
const sortedRows = sortBy(rows, sorts, fields, rowMap)
.map((row) => row.id)
.join(',');
expect(sortedRows).toBe('1,2,3,4,5,6,7,8,9,10');
});
it('should not sort rows if no rows are provided', () => {
const { sorts, fields } = withTestingData();
const rowMap = new Y.Map() as Y.Map<Y.Doc>;
const sortedRows = sortBy(rows, sorts, fields, rowMap)
.map((row) => row.id)
.join(',');
expect(sortedRows).toBe('1,2,3,4,5,6,7,8,9,10');
});
it('should return default data if rowMeta is not found', () => {
const { sorts, fields, rowMap } = withTestingData();
const sort = withNumberSort();
sorts.push([sort]);
rowMap.delete('1');
const sortedRows = sortBy(rows, sorts, fields, rowMap)
.map((row) => row.id)
.join(',');
expect(sortedRows).toBe('1,2,3,4,5,6,7,8,9,10');
});
it('should return default data if cell is not found', () => {
const { sorts, fields, rowMap } = withTestingData();
const sort = withNumberSort();
sorts.push([sort]);
const rowDoc = rowMap.get('1');
rowDoc
?.getMap(YjsEditorKey.data_section)
.get(YjsEditorKey.database_row)
?.get(YjsDatabaseKey.cells)
.delete('number_field');
const sortedRows = sortBy(rows, sorts, fields, rowMap)
.map((row) => row.id)
.join(',');
expect(sortedRows).toBe('1,2,3,4,5,6,7,8,9,10');
});
it('should sort by number field in ascending order', () => {
const { sorts, fields, rowMap } = withTestingData();
const sort = withNumberSort();
@ -311,4 +373,25 @@ describe('Database sortBy', () => {
.join(',');
expect(sortedRows).toBe('3,9,1,2,5,6,7,8,4,10');
});
it('should sort by CreatedAt field in ascending order', () => {
const { sorts, fields, rowMap } = withTestingData();
const sort = withCreatedAtSort();
sorts.push([sort]);
const sortedRows = sortBy(rows, sorts, fields, rowMap)
.map((row) => row.id)
.join(',');
expect(sortedRows).toBe('1,2,3,4,5,6,7,8,9,10');
});
it('should sort by LastEditedTime field', () => {
const { sorts, fields, rowMap } = withTestingData();
const sort = withLastModifiedSort();
sorts.push([sort]);
const sortedRows = sortBy(rows, sorts, fields, rowMap)
.map((row) => row.id)
.join(',');
expect(sortedRows).toBe('1,2,3,4,5,6,7,8,9,10');
});
});

View File

@ -0,0 +1,43 @@
import * as Y from 'yjs';
import { YDatabaseCell, YjsDatabaseKey } from '@/application/collab.type';
import { FieldType } from '@/application/database-yjs';
export function withTestingDateCell() {
const cell = new Y.Map() as YDatabaseCell;
cell.set(YjsDatabaseKey.id, 'date_field');
cell.set(YjsDatabaseKey.data, Date.now());
cell.set(YjsDatabaseKey.field_type, Number(FieldType.DateTime));
cell.set(YjsDatabaseKey.created_at, Date.now());
cell.set(YjsDatabaseKey.last_modified, Date.now());
cell.set(YjsDatabaseKey.end_timestamp, Date.now() + 1000);
cell.set(YjsDatabaseKey.include_time, true);
cell.set(YjsDatabaseKey.is_range, true);
cell.set(YjsDatabaseKey.reminder_id, 'reminderId');
return cell;
}
export function withTestingCheckboxCell() {
const cell = new Y.Map() as YDatabaseCell;
cell.set(YjsDatabaseKey.id, 'checkbox_field');
cell.set(YjsDatabaseKey.data, 'Yes');
cell.set(YjsDatabaseKey.field_type, Number(FieldType.Checkbox));
cell.set(YjsDatabaseKey.created_at, Date.now());
cell.set(YjsDatabaseKey.last_modified, Date.now());
return cell;
}
export function withTestingSingleOptionCell() {
const cell = new Y.Map() as YDatabaseCell;
cell.set(YjsDatabaseKey.id, 'single_select_field');
cell.set(YjsDatabaseKey.data, 'optionId');
cell.set(YjsDatabaseKey.field_type, Number(FieldType.SingleSelect));
cell.set(YjsDatabaseKey.created_at, Date.now());
cell.set(YjsDatabaseKey.last_modified, Date.now());
return cell;
}

View File

@ -1,7 +1,29 @@
import { YDatabaseFields, YDatabaseFilters, YDatabaseSorts } from '@/application/collab.type';
import {
YDatabase,
YDatabaseField,
YDatabaseFields,
YDatabaseFilters,
YDatabaseGroup,
YDatabaseGroupColumn,
YDatabaseGroupColumns,
YDatabaseLayoutSettings,
YDatabaseSorts,
YDatabaseView,
YDatabaseViews,
YDoc,
YjsDatabaseKey,
YjsEditorKey,
} from '@/application/collab.type';
import { withTestingFields } from '@/application/database-yjs/__tests__/withTestingField';
import { withTestingRowDataMap } from '@/application/database-yjs/__tests__/withTestingRows';
import {
withTestingRowData,
withTestingRowDataMap,
withTestingRows,
} from '@/application/database-yjs/__tests__/withTestingRows';
import * as Y from 'yjs';
import { withMultiSelectOptionFilter } from '@/application/database-yjs/__tests__/withTestingFilters';
import { withRichTextSort } from '@/application/database-yjs/__tests__/withTestingSorts';
import { metaIdFromRowId, RowMetaKey } from '@/application/database-yjs';
export function withTestingData() {
const doc = new Y.Doc();
@ -27,5 +49,133 @@ export function withTestingData() {
rowMap,
sorts,
filters,
doc,
};
}
export function withTestingDatabase(viewId: string) {
const doc = new Y.Doc();
const sharedRoot = doc.getMap(YjsEditorKey.data_section);
const database = new Y.Map() as YDatabase;
sharedRoot.set(YjsEditorKey.database, database);
const fields = withTestingFields() as YDatabaseFields;
database.set(YjsDatabaseKey.fields, fields);
database.set(YjsDatabaseKey.id, viewId);
const metas = new Y.Map();
database.set(YjsDatabaseKey.metas, metas);
metas.set(YjsDatabaseKey.iid, viewId);
const views = new Y.Map() as YDatabaseViews;
database.set(YjsDatabaseKey.views, views);
const view = new Y.Map() as YDatabaseView;
views.set('1', view);
view.set(YjsDatabaseKey.id, viewId);
view.set(YjsDatabaseKey.layout, 0);
view.set(YjsDatabaseKey.name, 'View 1');
view.set(YjsDatabaseKey.database_id, viewId);
const layoutSetting = new Y.Map() as YDatabaseLayoutSettings;
const calendarSetting = new Y.Map();
calendarSetting.set(YjsDatabaseKey.field_id, 'date_field');
layoutSetting.set('2', calendarSetting);
view.set(YjsDatabaseKey.layout_settings, layoutSetting);
const filters = new Y.Array() as YDatabaseFilters;
const filter = withMultiSelectOptionFilter();
filters.push([filter]);
const sorts = new Y.Array() as YDatabaseSorts;
const sort = withRichTextSort();
sorts.push([sort]);
const groups = new Y.Array();
const group = new Y.Map() as YDatabaseGroup;
groups.push([group]);
group.set(YjsDatabaseKey.id, 'g:single_select_field');
group.set(YjsDatabaseKey.field_id, 'single_select_field');
group.set(YjsDatabaseKey.type, '3');
group.set(YjsDatabaseKey.content, '');
const groupColumns = new Y.Array() as YDatabaseGroupColumns;
group.set(YjsDatabaseKey.groups, groupColumns);
const column1 = new Y.Map() as YDatabaseGroupColumn;
const column2 = new Y.Map() as YDatabaseGroupColumn;
column1.set(YjsDatabaseKey.id, '1');
column1.set(YjsDatabaseKey.visible, true);
column2.set(YjsDatabaseKey.id, 'single_select_field');
column2.set(YjsDatabaseKey.visible, true);
groupColumns.push([column1]);
groupColumns.push([column2]);
view.set(YjsDatabaseKey.filters, filters);
view.set(YjsDatabaseKey.sorts, sorts);
view.set(YjsDatabaseKey.groups, groups);
const fieldSettings = new Y.Map();
const fieldOrder = new Y.Array();
const rowOrders = new Y.Array();
Array.from(fields).forEach(([fieldId, field]) => {
const setting = new Y.Map();
if (fieldId === 'text_field') {
(field as YDatabaseField).set(YjsDatabaseKey.is_primary, true);
}
fieldOrder.push([fieldId]);
fieldSettings.set(fieldId, setting);
setting.set(YjsDatabaseKey.visibility, 0);
});
const rows = withTestingRows();
rows.forEach(({ id, height }) => {
const row = new Y.Map();
row.set(YjsDatabaseKey.id, id);
row.set(YjsDatabaseKey.height, height);
rowOrders.push([row]);
});
view.set(YjsDatabaseKey.field_settings, fieldSettings);
view.set(YjsDatabaseKey.field_orders, fieldOrder);
view.set(YjsDatabaseKey.row_orders, rowOrders);
const rowMapDoc = new Y.Doc();
const rowMapFolder = rowMapDoc.getMap();
rows.forEach((row, index) => {
const rowDoc = new Y.Doc();
const rowData = withTestingRowData(row.id, index);
const rowMeta = new Y.Map();
const parser = metaIdFromRowId('281e76fb-712e-59e2-8370-678bf0788355');
rowMeta.set(parser(RowMetaKey.IconId), '😊');
rowDoc.getMap(YjsEditorKey.data_section).set(YjsEditorKey.meta, rowMeta);
rowDoc.getMap(YjsEditorKey.data_section).set(YjsEditorKey.database_row, rowData);
rowMapFolder.set(row.id, rowDoc);
});
return {
rowDocMap: rowMapFolder as Y.Map<YDoc>,
doc: doc as YDoc,
};
}

View File

@ -4,7 +4,8 @@ import {
YjsDatabaseKey,
YMapFieldTypeOption,
} from '@/application/collab.type';
import { FieldType, SelectOptionColor } from '@/application/database-yjs';
import { FieldType } from '@/application/database-yjs';
import { SelectOptionColor } from '@/application/database-yjs/fields/select-option';
import * as Y from 'yjs';
export function withTestingFields() {
@ -39,6 +40,14 @@ export function withTestingFields() {
fields.set('checklist_field', checklistField);
const createdAtField = withCreatedAtTestingField();
fields.set('created_at_field', createdAtField);
const lastModifiedField = withLastModifiedTestingField();
fields.set('last_modified_field', lastModifiedField);
return fields;
}
@ -56,13 +65,31 @@ export function withRichTextTestingField() {
export function withNumberTestingField() {
const field = new Y.Map() as YDatabaseField;
const now = Date.now().toString();
field.set(YjsDatabaseKey.name, 'Number Field');
field.set(YjsDatabaseKey.id, 'number_field');
field.set(YjsDatabaseKey.type, String(FieldType.Number));
const typeOption = new Y.Map() as YDatabaseFieldTypeOption;
const numberTypeOption = new Y.Map() as YMapFieldTypeOption;
typeOption.set(String(FieldType.Number), numberTypeOption);
numberTypeOption.set(YjsDatabaseKey.format, '0');
field.set(YjsDatabaseKey.type_option, typeOption);
return field;
}
export function withRelationTestingField() {
const field = new Y.Map() as YDatabaseField;
const typeOption = new Y.Map() as YDatabaseFieldTypeOption;
const now = Date.now().toString();
field.set(YjsDatabaseKey.name, 'Relation Field');
field.set(YjsDatabaseKey.id, 'relation_field');
field.set(YjsDatabaseKey.type, String(FieldType.Relation));
field.set(YjsDatabaseKey.last_modified, now.valueOf());
field.set(YjsDatabaseKey.type_option, typeOption);
return field;
}
@ -151,3 +178,27 @@ export function withChecklistTestingField() {
return field;
}
export function withCreatedAtTestingField() {
const field = new Y.Map() as YDatabaseField;
const now = Date.now().toString();
field.set(YjsDatabaseKey.name, 'Created At Field');
field.set(YjsDatabaseKey.id, 'created_at_field');
field.set(YjsDatabaseKey.type, String(FieldType.CreatedTime));
field.set(YjsDatabaseKey.last_modified, now.valueOf());
return field;
}
export function withLastModifiedTestingField() {
const field = new Y.Map() as YDatabaseField;
const now = Date.now().toString();
field.set(YjsDatabaseKey.name, 'Last Modified Field');
field.set(YjsDatabaseKey.id, 'last_modified_field');
field.set(YjsDatabaseKey.type, String(FieldType.LastEditedTime));
field.set(YjsDatabaseKey.last_modified, now.valueOf());
return field;
}

View File

@ -39,6 +39,8 @@ export function withTestingRowData(id: string, index: number) {
rowData.set(YjsDatabaseKey.id, id);
rowData.set(YjsDatabaseKey.height, 37);
rowData.set(YjsDatabaseKey.last_modified, Date.now() + index * 1000);
rowData.set(YjsDatabaseKey.created_at, Date.now() + index * 1000);
const cells = new Y.Map() as YDatabaseCells;

View File

@ -89,3 +89,25 @@ export function withChecklistSort(isAscending: boolean = true) {
return sort;
}
export function withCreatedAtSort(isAscending: boolean = true) {
const sort = new Y.Map() as YDatabaseSort;
const sortJSON = isAscending ? sortsJson.sort_asc_created_at : sortsJson.sort_desc_created_at;
sort.set(YjsDatabaseKey.id, sortJSON.id);
sort.set(YjsDatabaseKey.field_id, sortJSON.field_id);
sort.set(YjsDatabaseKey.condition, sortJSON.condition === 'asc' ? '0' : '1');
return sort;
}
export function withLastModifiedSort(isAscending: boolean = true) {
const sort = new Y.Map() as YDatabaseSort;
const sortJSON = isAscending ? sortsJson.sort_asc_updated_at : sortsJson.sort_desc_updated_at;
sort.set(YjsDatabaseKey.id, sortJSON.id);
sort.set(YjsDatabaseKey.field_id, sortJSON.field_id);
sort.set(YjsDatabaseKey.condition, sortJSON.condition === 'asc' ? '0' : '1');
return sort;
}

View File

@ -1,5 +1,5 @@
import { FieldId, RowId } from '@/application/collab.type';
import { DateFormat, TimeFormat } from '@/application/database-yjs';
import { DateFormat, TimeFormat } from '@/application/database-yjs/index';
import { FieldType } from '@/application/database-yjs/database.type';
import React from 'react';
import { YArray } from 'yjs/dist/src/types/YArray';

View File

@ -18,7 +18,15 @@ export const getCellData = (rowId: string, fieldId: string, rowMetas: Y.Map<YDoc
};
export const metaIdFromRowId = (rowId: string) => {
const namespace = uuidParse(rowId);
let namespace: Uint8Array;
try {
namespace = uuidParse(rowId);
} catch (e) {
namespace = uuidParse(generateUUID());
}
return (key: RowMetaKey) => uuidv5(key, namespace).toString();
};
export const generateUUID = () => uuidv5(Date.now().toString(), uuidv5.URL);

View File

@ -1,5 +1,4 @@
import { YDatabase, YDatabaseRow, YDoc, YjsDatabaseKey, YjsEditorKey } from '@/application/collab.type';
import { Row } from '@/application/database-yjs/selector';
import { createContext, useContext } from 'react';
import * as Y from 'yjs';
@ -72,17 +71,3 @@ export function useDatabaseFields() {
return database.get(YjsDatabaseKey.fields);
}
export interface RowsState {
rowOrders: Row[];
}
export const RowsContext = createContext<RowsState | null>(null);
export function useRowsContext() {
return useContext(RowsContext);
}
export function useRows() {
return useRowsContext()?.rowOrders;
}

View File

@ -0,0 +1,21 @@
import { getTimeFormat, getDateFormat } from './utils';
import { expect } from '@jest/globals';
import { DateFormat, TimeFormat } from '@/application/database-yjs';
describe('DateFormat', () => {
it('should return time format', () => {
expect(getTimeFormat(TimeFormat.TwelveHour)).toEqual('h:mm A');
expect(getTimeFormat(TimeFormat.TwentyFourHour)).toEqual('HH:mm');
expect(getTimeFormat(56)).toEqual('HH:mm');
});
it('should return date format', () => {
expect(getDateFormat(DateFormat.US)).toEqual('YYYY/MM/DD');
expect(getDateFormat(DateFormat.ISO)).toEqual('YYYY-MM-DD');
expect(getDateFormat(DateFormat.Friendly)).toEqual('MMM DD, YYYY');
expect(getDateFormat(DateFormat.Local)).toEqual('MM/DD/YYYY');
expect(getDateFormat(DateFormat.DayMonthYear)).toEqual('DD/MM/YYYY');
expect(getDateFormat(56)).toEqual('YYYY-MM-DD');
});
});

View File

@ -78,6 +78,9 @@ function createPredicate(conditions: ((row: Row) => boolean)[]) {
export function filterBy(rows: Row[], filters: YDatabaseFilters, fields: YDatabaseFields, rowMetas: Y.Map<YDoc>) {
const filterArray = filters.toArray();
if (filterArray.length === 0 || rowMetas.size === 0 || fields.size === 0) return rows;
const conditions = filterArray.map((filter) => {
return (row: { id: string }) => {
const fieldId = filter.get(YjsDatabaseKey.field_id);
@ -142,12 +145,12 @@ export function textFilterCheck(data: string, content: string, condition: TextFi
export function numberFilterCheck(data: string, content: string, condition: number) {
if (isNaN(Number(data)) || isNaN(Number(content)) || data === '' || content === '') {
if (condition === NumberFilterCondition.NumberIsEmpty && data === '') {
return true;
if (condition === NumberFilterCondition.NumberIsEmpty) {
return data === '';
}
if (condition === NumberFilterCondition.NumberIsNotEmpty && data !== '') {
return true;
if (condition === NumberFilterCondition.NumberIsNotEmpty) {
return data !== '';
}
return false;
@ -169,10 +172,6 @@ export function numberFilterCheck(data: string, content: string, condition: numb
return decimal < filterDecimal;
case NumberFilterCondition.LessThanOrEqualTo:
return decimal <= filterDecimal;
case NumberFilterCondition.NumberIsEmpty:
return data === '';
case NumberFilterCondition.NumberIsNotEmpty:
return data !== '';
default:
return false;
}
@ -228,14 +227,6 @@ export function selectOptionFilterCheck(data: string, content: string, condition
case SelectOptionFilterCondition.OptionDoesNotContain:
return some(filterOptionIds, (option) => !selectedOptionIds.includes(option));
// Ensure selectedOptionIds is empty
case SelectOptionFilterCondition.OptionIsEmpty:
return selectedOptionIds.length === 0;
// Ensure selectedOptionIds is not empty
case SelectOptionFilterCondition.OptionIsNotEmpty:
return selectedOptionIds.length !== 0;
// Default case, if no conditions match
default:
return false;

View File

@ -14,18 +14,17 @@ import {
useDatabaseView,
useIsDatabaseRowPage,
useRowDocMap,
useRows,
useViewId,
} from '@/application/database-yjs/context';
import { filterBy, parseFilter } from '@/application/database-yjs/filter';
import { groupByField } from '@/application/database-yjs/group';
import { sortBy } from '@/application/database-yjs/sort';
import { useViewsIdSelector } from '@/application/folder-yjs';
import { parseYDatabaseCellToCell } from '@/components/database/components/cell/cell.parse';
import { DateTimeCell } from '@/components/database/components/cell/cell.type';
import dayjs from 'dayjs';
import { parseYDatabaseCellToCell } from '@/application/database-yjs/cell.parse';
import { DateTimeCell } from '@/application/database-yjs/cell.type';
import * as dayjs from 'dayjs';
import { throttle } from 'lodash-es';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import Y from 'yjs';
import { CalendarLayoutSetting, FieldType, FieldVisibility, Filter, RowMetaKey, SortCondition } from './database.type';
@ -72,7 +71,6 @@ export function useDatabaseViewsSelector(iidIndex: string) {
const [key] = viewItem;
const view = folderViews?.get(key);
console.log('view', view?.get(YjsFolderKey.bid), iidIndex);
if (
visibleViewsId.includes(key) &&
view &&
@ -82,7 +80,6 @@ export function useDatabaseViewsSelector(iidIndex: string) {
}
}
console.log('viewsId', viewsId);
setViewIds(viewsId);
};
@ -149,12 +146,6 @@ export function useFieldsSelector(visibilitys: FieldVisibility[] = defaultVisibl
return columns;
}
export function useRowsSelector() {
const rowOrders = useRows();
return useMemo(() => rowOrders ?? [], [rowOrders]);
}
export function useFieldSelector(fieldId: string) {
const database = useDatabase();
const [field, setField] = useState<YDatabaseField | null>(null);
@ -403,7 +394,7 @@ export function useRowsByGroup(groupId: string) {
if (!fieldId || !rowOrders || !rows) return;
const onConditionsChange = () => {
if (rows.size !== rowOrders?.length) return;
if (rows.size < rowOrders?.length) return;
const newResult = new Map<string, Row[]>();
@ -456,7 +447,7 @@ export function useRowOrdersSelector() {
if (!originalRowOrders || !rows) return;
if (originalRowOrders.length !== rows.size && !isDatabaseRowPage) return;
if (originalRowOrders.length > rows.size && !isDatabaseRowPage) return;
if (sorts?.length === 0 && filters?.length === 0) {
setRowOrders(originalRowOrders);
return;
@ -691,7 +682,7 @@ export function useCalendarLayoutSetting() {
export function usePrimaryFieldId() {
const database = useDatabase();
const [primaryFieldId, setPrimaryFieldId] = React.useState<string | null>(null);
const [primaryFieldId, setPrimaryFieldId] = useState<string | null>(null);
useEffect(() => {
const fields = database?.get(YjsDatabaseKey.fields);

View File

@ -15,6 +15,8 @@ import * as Y from 'yjs';
export function sortBy(rows: Row[], sorts: YDatabaseSorts, fields: YDatabaseFields, rowMetas: Y.Map<YDoc>) {
const sortArray = sorts.toArray();
if (sortArray.length === 0 || rowMetas.size === 0 || fields.size === 0) return rows;
const iteratees = sortArray.map((sort) => {
return (row: { id: string }) => {
const fieldId = sort.get(YjsDatabaseKey.field_id);
@ -26,8 +28,7 @@ export function sortBy(rows: Row[], sorts: YDatabaseSorts, fields: YDatabaseFiel
const defaultData = parseCellDataForSort(field, '');
if (!rowMeta) return defaultData;
const meta = rowMeta.getMap(YjsEditorKey.data_section).get(YjsEditorKey.database_row) as YDatabaseRow;
const meta = rowMeta?.getMap(YjsEditorKey.data_section).get(YjsEditorKey.database_row) as YDatabaseRow;
if (!meta) return defaultData;
if (fieldType === FieldType.LastEditedTime) {
@ -69,9 +70,9 @@ export function parseCellDataForSort(field: YDatabaseField, data: string | boole
return data === 'Yes';
case FieldType.SingleSelect:
case FieldType.MultiSelect:
return parseSelectOptionCellData(field, typeof data === 'string' ? data : '');
return parseSelectOptionCellData(field, data as string);
case FieldType.Checklist:
return parseChecklistData(typeof data === 'string' ? data : '')?.percentage ?? 0;
return parseChecklistData(data as string)?.percentage ?? 0;
case FieldType.DateTime:
return Number(data);
case FieldType.Relation:

View File

@ -1,5 +1,5 @@
import { ViewLayout, YFolder, YjsFolderKey } from '@/application/collab.type';
import { createContext, useCallback, useContext } from 'react';
import { createContext, useContext } from 'react';
import { useParams } from 'react-router-dom';
export interface Crumb {
@ -36,14 +36,3 @@ export const useNavigateToView = () => {
export const useCrumbs = () => {
return useContext(FolderContext)?.crumbs;
};
export const usePushCrumb = () => {
const { setCrumbs } = useContext(FolderContext) || {};
return useCallback(
(crumb: Crumb) => {
setCrumbs?.((prevCrumbs) => [...prevCrumbs, crumb]);
},
[setCrumbs]
);
};

View File

@ -0,0 +1,310 @@
import { CollabType } from '@/application/collab.type';
import * as Y from 'yjs';
import { withTestingYDoc } from '@/application/slate-yjs/__tests__/withTestingYjsEditor';
import { expect } from '@jest/globals';
import { getCollab, batchCollab, collabTypeToDBType } from '../cache';
import { applyYDoc } from '@/application/ydoc/apply';
import { getCollabDBName, openCollabDB } from '../cache/db';
import { StrategyType } from '../cache/types';
jest.mock('@/application/ydoc/apply', () => ({
applyYDoc: jest.fn(),
}));
jest.mock('../cache/db', () => ({
openCollabDB: jest.fn(),
getCollabDBName: jest.fn(),
}));
const emptyDoc = new Y.Doc();
const normalDoc = withTestingYDoc('1');
const mockFetcher = jest.fn();
const mockBatchFetcher = jest.fn();
describe('Cache functions', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('getCollab', () => {
describe('with CACHE_ONLY strategy', () => {
it('should throw error when no cache', async () => {
(openCollabDB as jest.Mock).mockResolvedValue(emptyDoc);
(getCollabDBName as jest.Mock).mockReturnValue('testDB');
await expect(
getCollab(
mockFetcher,
{
collabId: 'id1',
collabType: CollabType.Document,
},
StrategyType.CACHE_ONLY
)
).rejects.toThrow('No cache found');
});
it('should fetch collab with CACHE_ONLY strategy and existing cache', async () => {
(openCollabDB as jest.Mock).mockResolvedValue(normalDoc);
(getCollabDBName as jest.Mock).mockReturnValue('testDB');
const result = await getCollab(
mockFetcher,
{
collabId: 'id1',
collabType: CollabType.Document,
},
StrategyType.CACHE_ONLY
);
expect(result).toBe(normalDoc);
expect(mockFetcher).not.toHaveBeenCalled();
expect(applyYDoc).not.toHaveBeenCalled();
});
});
describe('with CACHE_FIRST strategy', () => {
it('should fetch collab with CACHE_FIRST strategy and existing cache', async () => {
(openCollabDB as jest.Mock).mockResolvedValue(normalDoc);
(getCollabDBName as jest.Mock).mockReturnValue('testDB');
mockFetcher.mockResolvedValue({ state: new Uint8Array() });
const result = await getCollab(
mockFetcher,
{
collabId: 'id1',
collabType: CollabType.Document,
},
StrategyType.CACHE_FIRST
);
expect(result).toBe(normalDoc);
expect(mockFetcher).not.toHaveBeenCalled();
expect(applyYDoc).not.toHaveBeenCalled();
});
it('should fetch collab with CACHE_FIRST strategy and no cache', async () => {
(openCollabDB as jest.Mock).mockResolvedValue(emptyDoc);
(getCollabDBName as jest.Mock).mockReturnValue('testDB');
mockFetcher.mockResolvedValue({ state: new Uint8Array() });
const result = await getCollab(
mockFetcher,
{
collabId: 'id1',
collabType: CollabType.Document,
},
StrategyType.CACHE_FIRST
);
expect(result).toBe(emptyDoc);
expect(mockFetcher).toHaveBeenCalled();
expect(applyYDoc).toHaveBeenCalled();
});
});
describe('with CACHE_AND_NETWORK strategy', () => {
it('should fetch collab with CACHE_AND_NETWORK strategy and existing cache', async () => {
(openCollabDB as jest.Mock).mockResolvedValue(normalDoc);
(getCollabDBName as jest.Mock).mockReturnValue('testDB');
mockFetcher.mockResolvedValue({ state: new Uint8Array() });
const result = await getCollab(
mockFetcher,
{
collabId: 'id1',
collabType: CollabType.Document,
},
StrategyType.CACHE_AND_NETWORK
);
expect(result).toBe(normalDoc);
expect(mockFetcher).toHaveBeenCalled();
expect(applyYDoc).toHaveBeenCalled();
});
it('should fetch collab with CACHE_AND_NETWORK strategy and no cache', async () => {
(openCollabDB as jest.Mock).mockResolvedValue(emptyDoc);
(getCollabDBName as jest.Mock).mockReturnValue('testDB');
mockFetcher.mockResolvedValue({ state: new Uint8Array() });
const result = await getCollab(
mockFetcher,
{
collabId: 'id1',
collabType: CollabType.Document,
},
StrategyType.CACHE_AND_NETWORK
);
expect(result).toBe(emptyDoc);
expect(mockFetcher).toHaveBeenCalled();
expect(applyYDoc).toHaveBeenCalled();
});
});
describe('with default strategy', () => {
it('should fetch collab with default strategy', async () => {
(openCollabDB as jest.Mock).mockResolvedValue(normalDoc);
(getCollabDBName as jest.Mock).mockReturnValue('testDB');
mockFetcher.mockResolvedValue({ state: new Uint8Array() });
const result = await getCollab(
mockFetcher,
{
collabId: 'id1',
collabType: CollabType.Document,
},
StrategyType.NETWORK_ONLY
);
expect(result).toBe(normalDoc);
expect(mockFetcher).toHaveBeenCalled();
expect(applyYDoc).toHaveBeenCalled();
});
});
});
describe('batchCollab', () => {
describe('with CACHE_ONLY strategy', () => {
it('should batch fetch collabs with CACHE_ONLY strategy and no cache', async () => {
(openCollabDB as jest.Mock).mockResolvedValue(emptyDoc);
(getCollabDBName as jest.Mock).mockReturnValue('testDB');
await expect(
batchCollab(
mockBatchFetcher,
[
{
collabId: 'id1',
collabType: CollabType.Document,
},
],
StrategyType.CACHE_ONLY
)
).rejects.toThrow('No cache found');
});
it('should batch fetch collabs with CACHE_ONLY strategy and existing cache', async () => {
(openCollabDB as jest.Mock).mockResolvedValue(normalDoc);
(getCollabDBName as jest.Mock).mockReturnValue('testDB');
await batchCollab(
mockBatchFetcher,
[
{
collabId: 'id1',
collabType: CollabType.Document,
},
],
StrategyType.CACHE_ONLY
);
expect(mockBatchFetcher).not.toHaveBeenCalled();
});
});
describe('with CACHE_FIRST strategy', () => {
it('should batch fetch collabs with CACHE_FIRST strategy and existing cache', async () => {
(openCollabDB as jest.Mock).mockResolvedValue(normalDoc);
(getCollabDBName as jest.Mock).mockReturnValue('testDB');
await batchCollab(
mockBatchFetcher,
[
{
collabId: 'id1',
collabType: CollabType.Document,
},
],
StrategyType.CACHE_FIRST
);
expect(mockBatchFetcher).not.toHaveBeenCalled();
});
it('should batch fetch collabs with CACHE_FIRST strategy and no cache', async () => {
(openCollabDB as jest.Mock).mockResolvedValue(emptyDoc);
(getCollabDBName as jest.Mock).mockReturnValue('testDB');
mockBatchFetcher.mockResolvedValue({ id1: [1, 2, 3] });
await batchCollab(
mockBatchFetcher,
[
{
collabId: 'id1',
collabType: CollabType.Document,
},
],
StrategyType.CACHE_FIRST
);
expect(mockBatchFetcher).toHaveBeenCalled();
expect(applyYDoc).toHaveBeenCalled();
});
});
describe('with CACHE_AND_NETWORK strategy', () => {
it('should batch fetch collabs with CACHE_AND_NETWORK strategy', async () => {
(openCollabDB as jest.Mock).mockResolvedValue(normalDoc);
(getCollabDBName as jest.Mock).mockReturnValue('testDB');
mockBatchFetcher.mockResolvedValue({ id1: [1, 2, 3] });
await batchCollab(
mockBatchFetcher,
[
{
collabId: 'id1',
collabType: CollabType.Document,
},
],
StrategyType.CACHE_AND_NETWORK
);
expect(mockBatchFetcher).toHaveBeenCalled();
expect(applyYDoc).toHaveBeenCalled();
});
it('should batch fetch collabs with CACHE_AND_NETWORK strategy and no cache', async () => {
(openCollabDB as jest.Mock).mockResolvedValue(emptyDoc);
(getCollabDBName as jest.Mock).mockReturnValue('testDB');
mockBatchFetcher.mockResolvedValue({ id1: [1, 2, 3] });
await batchCollab(
mockBatchFetcher,
[
{
collabId: 'id1',
collabType: CollabType.Document,
},
],
StrategyType.CACHE_AND_NETWORK
);
expect(mockBatchFetcher).toHaveBeenCalled();
expect(applyYDoc).toHaveBeenCalled();
});
});
});
});
describe('collabTypeToDBType', () => {
it('should return correct DB type', () => {
expect(collabTypeToDBType(CollabType.Document)).toBe('document');
expect(collabTypeToDBType(CollabType.Folder)).toBe('folder');
expect(collabTypeToDBType(CollabType.Database)).toBe('database');
expect(collabTypeToDBType(CollabType.WorkspaceDatabase)).toBe('databases');
expect(collabTypeToDBType(CollabType.DatabaseRow)).toBe('database_row');
expect(collabTypeToDBType(CollabType.UserAwareness)).toBe('user_awareness');
expect(collabTypeToDBType(CollabType.Empty)).toBe('');
});
});

View File

@ -0,0 +1,57 @@
import { expect } from '@jest/globals';
import { fetchCollab, batchFetchCollab } from '../fetch';
import { CollabType } from '@/application/collab.type';
import { APIService } from '@/application/services/js-services/wasm';
jest.mock('@/application/services/js-services/wasm', () => {
return {
APIService: {
getCollab: jest.fn(),
batchGetCollab: jest.fn(),
},
};
});
describe('Collab fetch functions with deduplication', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('fetchCollab', () => {
it('should fetch collab without duplicating requests', async () => {
const workspaceId = 'workspace1';
const id = 'id1';
const type = CollabType.Document;
const mockResponse = { data: 'mockData' };
(APIService.getCollab as jest.Mock).mockResolvedValue(mockResponse);
const result1 = fetchCollab(workspaceId, id, type);
const result2 = fetchCollab(workspaceId, id, type);
expect(result1).toBe(result2);
await expect(result1).resolves.toEqual(mockResponse);
expect(APIService.getCollab).toHaveBeenCalledTimes(1);
});
});
describe('batchFetchCollab', () => {
it('should batch fetch collabs without duplicating requests', async () => {
const workspaceId = 'workspace1';
const params = [
{ collabId: 'id1', collabType: CollabType.Document },
{ collabId: 'id2', collabType: CollabType.Folder },
];
const mockResponse = { data: 'mockData' };
(APIService.batchGetCollab as jest.Mock).mockResolvedValue(mockResponse);
const result1 = batchFetchCollab(workspaceId, params);
const result2 = batchFetchCollab(workspaceId, params);
expect(result1).toBe(result2);
await expect(result1).resolves.toEqual(mockResponse);
expect(APIService.batchGetCollab).toHaveBeenCalledTimes(1);
});
});
});

View File

@ -1,8 +1,8 @@
import { AuthService } from '@/application/services/services.type';
import { ProviderType, SignUpWithEmailPasswordParams } from '@/application/user.type';
import { APIService } from 'src/application/services/js-services/wasm';
import { signInSuccess } from '@/application/services/js-services/storage/auth';
import { invalidToken } from '@/application/services/js-services/storage';
import { signInSuccess } from '@/application/services/js-services/session/auth';
import { invalidToken } from 'src/application/services/js-services/session';
import { afterSignInDecorator } from '@/application/services/js-services/decorator';
export class JSAuthService implements AuthService {

View File

@ -1,9 +1,10 @@
import { YDoc } from '@/application/collab.type';
import { databasePrefix } from '@/application/constants';
import { getAuthInfo } from '@/application/services/js-services/storage';
import { IndexeddbPersistence } from 'y-indexeddb';
import * as Y from 'yjs';
const openedSet = new Set<string>();
/**
* Open the collaboration database, and return a function to close it
*/
@ -19,6 +20,10 @@ export async function openCollabDB(docName: string): Promise<YDoc> {
});
provider.on('synced', () => {
if (!openedSet.has(name)) {
openedSet.add(name);
}
resolve(true);
});
@ -27,9 +32,10 @@ export async function openCollabDB(docName: string): Promise<YDoc> {
return doc as YDoc;
}
export function getDBName(id: string, type: string) {
const { uuid } = getAuthInfo() || {};
export function getCollabDBName(id: string, type: string, uuid?: string) {
if (!uuid) {
return `${type}_${id}`;
}
if (!uuid) throw new Error('No user found');
return `${uuid}_${type}_${id}`;
}

View File

@ -0,0 +1,165 @@
import { CollabType, YDoc, YjsEditorKey, YSharedRoot } from '@/application/collab.type';
import { applyYDoc } from '@/application/ydoc/apply';
import { getCollabDBName, openCollabDB } from './db';
import { Fetcher, StrategyType } from './types';
export function collabTypeToDBType(type: CollabType) {
switch (type) {
case CollabType.Folder:
return 'folder';
case CollabType.Document:
return 'document';
case CollabType.Database:
return 'database';
case CollabType.WorkspaceDatabase:
return 'databases';
case CollabType.DatabaseRow:
return 'database_row';
case CollabType.UserAwareness:
return 'user_awareness';
default:
return '';
}
}
const collabSharedRootKeyMap = {
[CollabType.Folder]: YjsEditorKey.folder,
[CollabType.Document]: YjsEditorKey.document,
[CollabType.Database]: YjsEditorKey.database,
[CollabType.WorkspaceDatabase]: YjsEditorKey.workspace_database,
[CollabType.DatabaseRow]: YjsEditorKey.database_row,
[CollabType.UserAwareness]: YjsEditorKey.user_awareness,
[CollabType.Empty]: YjsEditorKey.empty,
};
export function hasCache(doc: YDoc, type: CollabType) {
const data = doc.getMap(YjsEditorKey.data_section) as YSharedRoot;
return data.has(collabSharedRootKeyMap[type] as string);
}
export async function getCollab(
fetcher: Fetcher<{
state: Uint8Array;
}>,
{
collabId,
collabType,
uuid,
}: {
uuid?: string;
collabId: string;
collabType: CollabType;
},
strategy: StrategyType = StrategyType.CACHE_AND_NETWORK
) {
const name = getCollabDBName(collabId, collabTypeToDBType(collabType), uuid);
const collab = await openCollabDB(name);
const exist = hasCache(collab, collabType);
switch (strategy) {
case StrategyType.CACHE_ONLY: {
if (!exist) {
throw new Error('No cache found');
}
return collab;
}
case StrategyType.CACHE_FIRST: {
if (!exist) {
await revalidateCollab(fetcher, collab);
}
return collab;
}
case StrategyType.CACHE_AND_NETWORK: {
if (!exist) {
await revalidateCollab(fetcher, collab);
} else {
void revalidateCollab(fetcher, collab);
}
return collab;
}
default: {
await revalidateCollab(fetcher, collab);
return collab;
}
}
}
async function revalidateCollab(
fetcher: Fetcher<{
state: Uint8Array;
}>,
collab: YDoc
) {
const { state } = await fetcher();
applyYDoc(collab, state);
}
export async function batchCollab(
batchFetcher: Fetcher<Record<string, number[]>>,
collabs: {
collabId: string;
collabType: CollabType;
uuid?: string;
}[],
strategy: StrategyType = StrategyType.CACHE_AND_NETWORK,
itemCallback?: (id: string, doc: YDoc) => void
) {
const collabMap = new Map<string, YDoc>();
for (const { collabId, collabType, uuid } of collabs) {
const name = getCollabDBName(collabId, collabTypeToDBType(collabType), uuid);
const collab = await openCollabDB(name);
const exist = hasCache(collab, collabType);
collabMap.set(collabId, collab);
if (exist) {
itemCallback?.(collabId, collab);
}
}
const notCacheIds = collabs.filter(({ collabId, collabType }) => {
const id = collabMap.get(collabId);
if (!id) return false;
return !hasCache(id, collabType);
});
if (strategy === StrategyType.CACHE_ONLY) {
if (notCacheIds.length > 0) {
throw new Error('No cache found');
}
return;
}
if (strategy === StrategyType.CACHE_FIRST && notCacheIds.length === 0) {
return;
}
const states = await batchFetcher();
for (const [collabId, data] of Object.entries(states)) {
const info = collabs.find((item) => item.collabId === collabId);
const collab = collabMap.get(collabId);
if (!info || !collab) {
continue;
}
const state = new Uint8Array(data);
applyYDoc(collab, state);
itemCallback?.(collabId, collab);
}
}

View File

@ -0,0 +1,12 @@
export enum StrategyType {
// Cache only: return the cache if it exists, otherwise throw an error
CACHE_ONLY = 'CACHE_ONLY',
// Cache first: return the cache if it exists, otherwise fetch from the network
CACHE_FIRST = 'CACHE_FIRST',
// Cache and network: return the cache if it exists, otherwise fetch from the network and update the cache
CACHE_AND_NETWORK = 'CACHE_AND_NETWORK',
// Network only: fetch from the network and update the cache
NETWORK_ONLY = 'NETWORK_ONLY',
}
export type Fetcher<T> = () => Promise<T>;

View File

@ -1,16 +1,16 @@
import { CollabType, YDatabase, YDoc, YjsDatabaseKey, YjsEditorKey } from '@/application/collab.type';
import {
batchCollabs,
getCollabStorage,
getCollabStorageWithAPICall,
getCurrentWorkspace,
} from '@/application/services/js-services/storage';
import { batchCollab, getCollab } from '@/application/services/js-services/cache';
import { StrategyType } from '@/application/services/js-services/cache/types';
import { batchFetchCollab, fetchCollab } from '@/application/services/js-services/fetch';
import { getCurrentWorkspace } from 'src/application/services/js-services/session';
import { DatabaseService } from '@/application/services/services.type';
import * as Y from 'yjs';
export class JSDatabaseService implements DatabaseService {
private loadedDatabaseId: Set<string> = new Set();
private loadedWorkspaceId: Set<string> = new Set();
private cacheDatabaseRowDocMap: Map<string, Y.Doc> = new Map();
constructor() {
@ -28,22 +28,30 @@ export class JSDatabaseService implements DatabaseService {
throw new Error('Workspace database not found');
}
const workspaceDatabase = await getCollabStorageWithAPICall(
workspace.id,
workspace.workspaceDatabaseId,
CollabType.WorkspaceDatabase
const isLoaded = this.loadedWorkspaceId.has(workspace.id);
const workspaceDatabase = await getCollab(
() => {
return fetchCollab(workspace.id, workspace.workspaceDatabaseId, CollabType.WorkspaceDatabase);
},
{
collabId: workspace.workspaceDatabaseId,
collabType: CollabType.WorkspaceDatabase,
},
isLoaded ? StrategyType.CACHE_FIRST : StrategyType.CACHE_AND_NETWORK
);
if (!isLoaded) {
this.loadedWorkspaceId.add(workspace.id);
}
return workspaceDatabase.getMap(YjsEditorKey.data_section).get(YjsEditorKey.workspace_database).toJSON() as {
views: string[];
database_id: string;
}[];
}
async openDatabase(
databaseId: string,
rowIds?: string[]
): Promise<{
async openDatabase(databaseId: string): Promise<{
databaseDoc: YDoc;
rows: Y.Map<YDoc>;
}> {
@ -68,13 +76,18 @@ export class JSDatabaseService implements DatabaseService {
const rowsFolder: Y.Map<YDoc> = rootRowsDoc.getMap();
let databaseDoc: YDoc | undefined = undefined;
const databaseDoc = await getCollab(
() => {
return fetchCollab(workspaceId, databaseId, CollabType.Database);
},
{
collabId: databaseId,
collabType: CollabType.Database,
},
isLoaded ? StrategyType.CACHE_FIRST : StrategyType.CACHE_AND_NETWORK
);
if (isLoaded) {
databaseDoc = (await getCollabStorage(databaseId, CollabType.Database)).doc;
} else {
databaseDoc = await getCollabStorageWithAPICall(workspaceId, databaseId, CollabType.Database);
}
if (!isLoaded) this.loadedDatabaseId.add(databaseId);
const database = databaseDoc.getMap(YjsEditorKey.data_section)?.get(YjsEditorKey.database) as YDatabase;
const viewId = database.get(YjsDatabaseKey.metas)?.get(YjsDatabaseKey.iid)?.toString();
@ -87,47 +100,50 @@ export class JSDatabaseService implements DatabaseService {
throw new Error('Database rows not found');
}
const ids = rowIds ? rowIds : rowOrdersIds.map((item) => item.id);
if (isLoaded) {
for (const id of ids) {
const { doc } = await getCollabStorage(id, CollabType.DatabaseRow);
const rowsParams = rowOrdersIds.map((item) => ({
collabId: item.id,
collabType: CollabType.DatabaseRow,
}));
void batchCollab(
() => {
return batchFetchCollab(workspaceId, rowsParams);
},
rowsParams,
isLoaded ? StrategyType.CACHE_FIRST : StrategyType.CACHE_AND_NETWORK,
(id: string, doc: YDoc) => {
if (!rowsFolder.has(id)) {
rowsFolder.set(id, doc);
}
}
} else {
void this.loadDatabaseRows(workspaceId, ids, (id, row) => {
if (!rowsFolder.has(id)) {
rowsFolder.set(id, row);
}
});
}
);
this.loadedDatabaseId.add(databaseId);
// Update rows if there are new rows added after the database has been loaded
rowOrders?.observe((event) => {
if (event.changes.added.size > 0) {
const rowIds = rowOrders.toJSON() as {
id: string;
}[];
if (!rowIds) {
// Update rows if new rows are added
rowOrders?.observe((event) => {
if (event.changes.added.size > 0) {
const rowIds = rowOrders.toJSON() as {
id: string;
}[];
const params = rowIds.map((item) => ({
collabId: item.id,
collabType: CollabType.DatabaseRow,
}));
console.log('Update rows', rowIds);
void this.loadDatabaseRows(
workspaceId,
rowIds.map((item) => item.id),
(rowId: string, rowDoc) => {
if (!rowsFolder.has(rowId)) {
rowsFolder.set(rowId, rowDoc);
}
void batchCollab(
() => {
return batchFetchCollab(workspaceId, params);
},
params,
StrategyType.CACHE_AND_NETWORK,
(id: string, doc: YDoc) => {
if (!rowsFolder.has(id)) {
rowsFolder.set(id, doc);
}
);
}
});
}
}
);
}
});
return {
databaseDoc,
@ -135,21 +151,6 @@ export class JSDatabaseService implements DatabaseService {
};
}
async loadDatabaseRows(workspaceId: string, rowIds: string[], rowCallback: (rowId: string, rowDoc: YDoc) => void) {
try {
await batchCollabs(
workspaceId,
rowIds.map((id) => ({
object_id: id,
collab_type: CollabType.DatabaseRow,
})),
rowCallback
);
} catch (e) {
console.error(e);
}
}
async closeDatabase(databaseId: string) {
this.cacheDatabaseRowDocMap.delete(databaseId);
}

Some files were not shown because too many files have changed in this diff Show More