mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
chore: merge branch 'upstream/main' into feat/billing-client
This commit is contained in:
28
.github/workflows/build_bot.yaml
vendored
28
.github/workflows/build_bot.yaml
vendored
@ -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 }}
|
2
.github/workflows/deploy_test_web.yaml
vendored
2
.github/workflows/deploy_test_web.yaml
vendored
@ -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/"
|
||||
|
16
.github/workflows/web_coverage.yaml
vendored
16
.github/workflows/web_coverage.yaml
vendored
@ -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
|
||||
|
||||
|
@ -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),
|
||||
]);
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
|
@ -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),
|
||||
|
@ -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';
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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();
|
||||
}
|
@ -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(
|
||||
|
@ -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,
|
||||
),
|
||||
),
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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),
|
||||
// ),
|
||||
// ),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
@ -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({
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
@ -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,
|
||||
// ),
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -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();
|
||||
|
@ -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() {
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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),
|
||||
|
@ -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),
|
||||
],
|
||||
|
@ -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(
|
||||
|
@ -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),
|
||||
],
|
||||
|
@ -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)
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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},
|
||||
|
@ -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(),
|
||||
|
@ -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),
|
||||
|
@ -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()),
|
||||
);
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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:
|
||||
|
@ -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
|
||||
|
@ -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();
|
||||
});
|
||||
}
|
44
frontend/appflowy_flutter/test/bloc_test/chat_test/util.dart
Normal file
44
frontend/appflowy_flutter/test/bloc_test/chat_test/util.dart
Normal 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);
|
||||
}
|
@ -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() {
|
||||
|
@ -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.
|
||||
|
@ -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");
|
||||
|
62
frontend/appflowy_tauri/src-tauri/Cargo.lock
generated
62
frontend/appflowy_tauri/src-tauri/Cargo.lock
generated
@ -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",
|
||||
|
@ -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" }
|
||||
|
28
frontend/appflowy_web/wasm-libs/Cargo.lock
generated
28
frontend/appflowy_web/wasm-libs/Cargo.lock
generated
@ -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",
|
||||
|
@ -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" }
|
||||
|
@ -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"
|
||||
|
@ -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
@ -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
@ -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 />)
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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/',
|
||||
],
|
||||
};
|
@ -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 {
|
||||
|
@ -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",
|
||||
|
1073
frontend/appflowy_web_app/pnpm-lock.yaml
generated
1073
frontend/appflowy_web_app/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -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`);
|
||||
}
|
||||
|
114
frontend/appflowy_web_app/server.cjs
Normal file
114
frontend/appflowy_web_app/server.cjs
Normal 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();
|
59
frontend/appflowy_web_app/src-tauri/Cargo.lock
generated
59
frontend/appflowy_web_app/src-tauri/Cargo.lock
generated
@ -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",
|
||||
|
@ -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" }
|
||||
|
@ -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> {
|
||||
|
@ -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();
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
@ -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 },
|
||||
],
|
||||
],
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
@ -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');
|
||||
});
|
||||
});
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
@ -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;
|
||||
}
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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';
|
@ -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);
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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');
|
||||
});
|
||||
});
|
@ -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;
|
||||
|
@ -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);
|
||||
|
@ -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:
|
||||
|
@ -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]
|
||||
);
|
||||
};
|
||||
|
@ -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('');
|
||||
});
|
||||
});
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
@ -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 {
|
||||
|
@ -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}`;
|
||||
}
|
165
frontend/appflowy_web_app/src/application/services/js-services/cache/index.ts
vendored
Normal file
165
frontend/appflowy_web_app/src/application/services/js-services/cache/index.ts
vendored
Normal 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);
|
||||
}
|
||||
}
|
12
frontend/appflowy_web_app/src/application/services/js-services/cache/types.ts
vendored
Normal file
12
frontend/appflowy_web_app/src/application/services/js-services/cache/types.ts
vendored
Normal 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>;
|
@ -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
Reference in New Issue
Block a user