Compare commits

...
15 Commits
191 changed files with 3901 additions and 4213 deletions

No files matched your search

+1 -1
View File
@@ -69,4 +69,4 @@ jobs:
SCRIPT_AFTER: |
docker build -t appflowy-web-app .
docker rm -f appflowy-web-app || true
docker run -d -p 80:80 -p 443:443 --name appflowy-web-app appflowy-web-app
docker run -d -p 80:80 -p 443:443 --env-file .env --name appflowy-web-app appflowy-web-app
+1 -1
View File
@@ -26,7 +26,7 @@ CARGO_MAKE_EXTEND_WORKSPACE_MAKEFILE = true
CARGO_MAKE_CRATE_FS_NAME = "dart_ffi"
CARGO_MAKE_CRATE_NAME = "dart-ffi"
LIB_NAME = "dart_ffi"
APPFLOWY_VERSION = "0.6.0"
APPFLOWY_VERSION = "0.6.1"
FLUTTER_DESKTOP_FEATURES = "dart"
PRODUCT_NAME = "AppFlowy"
MACOSX_DEPLOYMENT_TARGET = "11.0"
@@ -4,6 +4,7 @@ import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/home/space/mobile_space_header.dart';
import 'package:appflowy/mobile/presentation/home/space/mobile_space_menu.dart';
import 'package:appflowy/mobile/presentation/page_item/mobile_view_item.dart';
import 'package:appflowy/mobile/presentation/presentation.dart';
import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart';
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
import 'package:appflowy/workspace/application/view/view_bloc.dart';
@@ -22,6 +23,18 @@ class MobileSpace extends StatefulWidget {
}
class _MobileSpaceState extends State<MobileSpace> {
@override
void initState() {
super.initState();
createNewPageNotifier.addListener(_createNewPage);
}
@override
void dispose() {
createNewPageNotifier.removeListener(_createNewPage);
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocBuilder<SpaceBloc, SpaceState>(
@@ -81,6 +94,14 @@ class _MobileSpaceState extends State<MobileSpace> {
},
);
}
void _createNewPage() {
context.read<SpaceBloc>().add(
SpaceEvent.createPage(
name: LocaleKeys.menuAppHeader_defaultNewPageName.tr(),
),
);
}
}
class _Pages extends StatelessWidget {
@@ -1,18 +1,15 @@
import 'dart:ui';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/application/mobile_router.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy/workspace/application/workspace/workspace_service.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
final PropertyValueNotifier<ViewLayoutPB?> createNewPageNotifier =
PropertyValueNotifier(null);
const _homeLabel = 'home';
const _addLabel = 'add';
const _notificationLabel = 'notification';
@@ -93,7 +90,7 @@ class MobileBottomNavigationBar extends StatelessWidget {
void _onTap(BuildContext context, int bottomBarIndex) {
if (_items[bottomBarIndex].label == _addLabel) {
// show an add dialog
_showCreatePageBottomSheet(context);
createNewPageNotifier.value = ViewLayoutPB.Document;
return;
}
// When navigating to a new branch, it's recommended to use the goBranch
@@ -108,40 +105,4 @@ class MobileBottomNavigationBar extends StatelessWidget {
initialLocation: bottomBarIndex == navigationShell.currentIndex,
);
}
void _showCreatePageBottomSheet(BuildContext context) {
showMobileBottomSheet(
context,
showHeader: true,
title: LocaleKeys.sideBar_addAPage.tr(),
showDragHandle: true,
showCloseButton: true,
useRootNavigator: true,
builder: (sheetContext) {
return AddNewPageWidgetBottomSheet(
view: ViewPB(),
onAction: (layout) async {
Navigator.of(sheetContext).pop();
final currentWorkspaceId =
await FolderEventReadCurrentWorkspace().send();
await currentWorkspaceId.fold((s) async {
final workspaceService = WorkspaceService(workspaceId: s.id);
final result = await workspaceService.createView(
name: LocaleKeys.menuAppHeader_defaultNewPageName.tr(),
viewSection: ViewSectionPB.Private,
layout: layout,
);
result.fold((s) {
context.pushView(s);
}, (e) {
Log.error('Failed to create new page: $e');
});
}, (e) {
Log.error('Failed to read current workspace: $e');
});
},
);
},
);
}
}
@@ -1,52 +1,102 @@
import 'dart:io';
import 'package:appflowy/workspace/application/export/document_exporter.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-document/entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:flowy_infra/uuid.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'document_share_bloc.freezed.dart';
const _url = 'https://test.appflowy.io';
class DocumentShareBloc extends Bloc<DocumentShareEvent, DocumentShareState> {
DocumentShareBloc({
required this.view,
}) : super(const DocumentShareState.initial()) {
}) : super(DocumentShareState.initial()) {
on<DocumentShareEvent>((event, emit) async {
await event.when(
initial: () async {
final publishInfo = await ViewBackendService.getPublishInfo(view);
publishInfo.fold((s) {
emit(
state.copyWith(
isPublished: true,
url: '$_url/${s.namespace}/${s.publishName}',
),
);
}, (f) {
emit(state.copyWith(isPublished: false, url: ''));
});
},
share: (type, path) async {
if (DocumentShareType.unimplemented.contains(type)) {
Log.error('DocumentShareType $type is not implemented');
return;
}
emit(const DocumentShareState.loading());
emit(state.copyWith(isLoading: true));
final exporter = DocumentExporter(view);
final FlowyResult<ExportDataPB, FlowyError> result =
await exporter.export(type.exportType).then((value) {
return value.fold(
(s) {
if (path != null) {
switch (type) {
case DocumentShareType.markdown:
return FlowyResult.success(_saveMarkdownToPath(s, path));
case DocumentShareType.html:
return FlowyResult.success(_saveHTMLToPath(s, path));
default:
break;
}
}
return FlowyResult.failure(FlowyError());
},
(f) => FlowyResult.failure(f),
final result = await _export(type, path);
emit(
state.copyWith(
isLoading: false,
exportResult: result,
),
);
},
publish: (url) async {
// todo: optimize the logic
const prefix = 'appflowy';
final name = '${view.name}-${uuid()}'.substring(0, 19);
// set space name
try {
final nameSpace =
await ViewBackendService.getPublishNameSpace().getOrThrow();
if (nameSpace.namespace != prefix) {
await ViewBackendService.setPublishNameSpace(prefix).getOrThrow();
}
final result = await ViewBackendService.publish(view, name: name);
emit(
state.copyWith(
isPublished: true,
publishResult: result,
url: '$_url/${nameSpace.namespace}/$name',
),
);
});
} catch (e) {
Log.error('publish error: $e');
emit(DocumentShareState.finish(result));
emit(
state.copyWith(
isPublished: false,
publishResult: FlowyResult.failure(
FlowyError(msg: 'publish error: $e'),
),
url: '',
),
);
}
},
unPublish: () async {
await ViewBackendService.unpublish(view);
emit(
state.copyWith(
isPublished: false,
publishResult: null,
url: '',
),
);
},
);
});
@@ -54,6 +104,31 @@ class DocumentShareBloc extends Bloc<DocumentShareEvent, DocumentShareState> {
final ViewPB view;
late final exporter = DocumentExporter(view);
Future<FlowyResult<ExportDataPB, FlowyError>> _export(
DocumentShareType type,
String? path,
) async {
final result = await exporter.export(type.exportType);
return result.fold(
(s) {
if (path != null) {
switch (type) {
case DocumentShareType.markdown:
return FlowySuccess(_saveMarkdownToPath(s, path));
case DocumentShareType.html:
return FlowySuccess(_saveHTMLToPath(s, path));
default:
break;
}
}
return FlowyResult.failure(FlowyError());
},
(f) => FlowyResult.failure(f),
);
}
ExportDataPB _saveMarkdownToPath(String markdown, String path) {
File(path).writeAsStringSync(markdown);
return ExportDataPB()
@@ -93,15 +168,28 @@ enum DocumentShareType {
@freezed
class DocumentShareEvent with _$DocumentShareEvent {
const factory DocumentShareEvent.share(DocumentShareType type, String? path) =
Share;
const factory DocumentShareEvent.initial() = _Initial;
const factory DocumentShareEvent.share(
DocumentShareType type,
String? path,
) = _Share;
const factory DocumentShareEvent.publish(String url) = _Publish;
const factory DocumentShareEvent.unPublish() = _UnPublish;
}
@freezed
class DocumentShareState with _$DocumentShareState {
const factory DocumentShareState.initial() = _Initial;
const factory DocumentShareState.loading() = _Loading;
const factory DocumentShareState.finish(
FlowyResult<ExportDataPB, FlowyError> successOrFail,
) = _Finish;
const factory DocumentShareState({
required bool isLoading,
FlowyResult<ExportDataPB, FlowyError>? exportResult,
required bool isPublished,
FlowyResult<void, FlowyError>? publishResult,
required String url,
}) = _DocumentShareState;
factory DocumentShareState.initial() => const DocumentShareState(
isLoading: false,
isPublished: false,
url: '',
);
}
@@ -73,8 +73,7 @@ class _AppFlowyMobileToolbarState extends State<AppFlowyMobileToolbar> {
ValueListenableBuilder(
valueListenable: isKeyboardShow,
builder: (context, isKeyboardShow, __) {
return AnimatedContainer(
duration: const Duration(milliseconds: 110),
return SizedBox(
// only adding padding when the keyboard is triggered by editor
height: isKeyboardShow && widget.editorState.selection != null
? widget.toolbarHeight
@@ -383,37 +382,26 @@ class _MobileToolbarState extends State<_MobileToolbar>
return ValueListenableBuilder(
valueListenable: cachedKeyboardHeight,
builder: (_, height, ___) {
var paddingHeight = height;
if (Platform.isAndroid) {
// use the viewInsets to get the keyboard height on Android
paddingHeight = MediaQuery.of(context).viewInsets.bottom;
// if the padding height is 0 and the keyboard height is not 0,
// use the keyboard height
if (paddingHeight == 0 && height != 0) {
paddingHeight = height + MediaQuery.of(context).viewPadding.bottom;
}
}
debugPrint('Keyboard height: $paddingHeight');
return AnimatedContainer(
duration: const Duration(microseconds: 110),
height: paddingHeight,
child: ValueListenableBuilder(
valueListenable: showMenuNotifier,
builder: (_, showingMenu, __) {
return AnimatedContainer(
duration: const Duration(microseconds: 110),
height: height,
child: (showingMenu && selectedMenuIndex != null)
? widget.toolbarItems[selectedMenuIndex!].menuBuilder?.call(
context,
widget.editorState,
this,
) ??
const SizedBox.shrink()
: const SizedBox.shrink(),
);
},
),
return ValueListenableBuilder(
valueListenable: showMenuNotifier,
builder: (_, showingMenu, __) {
var paddingHeight = height;
if (Platform.isAndroid) {
paddingHeight =
height + MediaQuery.of(context).viewPadding.bottom;
}
return SizedBox(
height: paddingHeight,
child: (showingMenu && selectedMenuIndex != null)
? widget.toolbarItems[selectedMenuIndex!].menuBuilder?.call(
context,
widget.editorState,
this,
) ??
const SizedBox.shrink()
: const SizedBox.shrink(),
);
},
);
},
);
@@ -0,0 +1,246 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/application/document_share_bloc.dart';
import 'package:appflowy/workspace/presentation/home/toast.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/widget/rounded_button.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class PublishTab extends StatelessWidget {
const PublishTab({super.key});
@override
Widget build(BuildContext context) {
return BlocConsumer<DocumentShareBloc, DocumentShareState>(
listener: (context, state) {
if (state.publishResult != null) {
state.publishResult!.fold(
(value) => showSnackBarMessage(context, 'Published successfully'),
(error) =>
showSnackBarMessage(context, 'Failed to publish: $error'),
);
}
},
builder: (context, state) {
return state.isPublished
? _PublishedWidget(
url: state.url,
onVisitSite: () {},
onUnPublish: () {
context
.read<DocumentShareBloc>()
.add(const DocumentShareEvent.unPublish());
},
)
: _UnPublishWidget(
onPublish: () => context
.read<DocumentShareBloc>()
.add(const DocumentShareEvent.publish('')),
);
},
);
}
}
class _PublishedWidget extends StatefulWidget {
const _PublishedWidget({
required this.url,
required this.onVisitSite,
required this.onUnPublish,
});
final String url;
final VoidCallback onVisitSite;
final VoidCallback onUnPublish;
@override
State<_PublishedWidget> createState() => _PublishedWidgetState();
}
class _PublishedWidgetState extends State<_PublishedWidget> {
final controller = TextEditingController();
@override
void initState() {
super.initState();
controller.text = widget.url;
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const VSpace(16),
const _PublishTabHeader(),
const VSpace(16),
_PublishUrl(
controller: controller,
onCopy: (url) {},
onSubmitted: (url) {},
),
const VSpace(16),
Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildButton(
context,
name: LocaleKeys.shareAction_unPublish.tr(),
borderColor: const Color(0x1E14171B),
onTap: widget.onUnPublish,
),
const Spacer(),
_buildButton(
context,
name: LocaleKeys.shareAction_visitSite.tr(),
backgroundColor: Theme.of(context).colorScheme.primary,
textColor: Colors.white,
onTap: () {
safeLaunchUrl(controller.text);
},
),
],
),
],
);
}
Widget _buildButton(
BuildContext context, {
required String name,
Color? backgroundColor,
Color borderColor = Colors.transparent,
Color? textColor,
required VoidCallback onTap,
}) {
return SizedBox(
height: 36,
width: 189,
child: FlowyButton(
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: borderColor),
),
text: FlowyText.regular(
name,
textAlign: TextAlign.center,
color: textColor,
),
onTap: onTap,
),
);
}
}
class _UnPublishWidget extends StatelessWidget {
const _UnPublishWidget({
required this.onPublish,
});
final VoidCallback onPublish;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const VSpace(16),
const _PublishTabHeader(),
const VSpace(16),
RoundedTextButton(
height: 36,
title: LocaleKeys.shareAction_publish.tr(),
padding: const EdgeInsets.symmetric(vertical: 9.0),
fontSize: 14.0,
textColor: Theme.of(context).colorScheme.onPrimary,
onPressed: onPublish,
),
],
);
}
}
class _PublishTabHeader extends StatelessWidget {
const _PublishTabHeader();
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const FlowySvg(FlowySvgs.share_publish_s),
const HSpace(6),
FlowyText(LocaleKeys.shareAction_publishToTheWeb.tr()),
],
),
const VSpace(4),
FlowyText.regular(
LocaleKeys.shareAction_publishToTheWebHint.tr(),
fontSize: 12,
maxLines: 3,
color: Theme.of(context).hintColor,
),
],
);
}
}
class _PublishUrl extends StatelessWidget {
const _PublishUrl({
required this.controller,
required this.onCopy,
required this.onSubmitted,
});
final TextEditingController controller;
final void Function(String url) onCopy;
final void Function(String url) onSubmitted;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 36,
child: FlowyTextField(
autoFocus: false,
controller: controller,
suffixIcon: _buildCopyLinkIcon(),
),
);
}
Widget _buildCopyLinkIcon() {
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () => onCopy(controller.text),
child: Container(
width: 36,
height: 36,
alignment: Alignment.center,
padding: const EdgeInsets.all(10),
decoration: const BoxDecoration(
border: Border(left: BorderSide(color: Color(0x141F2329))),
),
child: const FlowySvg(
FlowySvgs.m_toolbar_link_m,
),
),
),
);
}
}
@@ -1,6 +1,7 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/application/document_share_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/plugins/document/presentation/share/share_menu.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/util/string_extension.dart';
import 'package:appflowy/workspace/application/export/document_exporter.dart';
@@ -13,6 +14,7 @@ import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/file_picker/file_picker_service.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/widget/rounded_button.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -28,23 +30,44 @@ class DocumentShareButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => getIt<DocumentShareBloc>(param1: view),
create: (context) => getIt<DocumentShareBloc>(param1: view)
..add(const DocumentShareEvent.initial()),
child: BlocListener<DocumentShareBloc, DocumentShareState>(
listener: (context, state) {
state.mapOrNull(
finish: (state) {
state.successOrFail.fold(
(data) => _handleExportData(context, data),
_handleExportError,
);
},
);
if (state.isLoading == false && state.exportResult != null) {
state.exportResult!.fold(
(data) => _handleExportData(context, data),
_handleExportError,
);
}
},
child: BlocBuilder<DocumentShareBloc, DocumentShareState>(
builder: (context, state) => SizedBox(
height: 32.0,
child: IntrinsicWidth(child: ShareActionList(view: view)),
),
builder: (context, state) {
final shareBloc = context.read<DocumentShareBloc>();
return SizedBox(
height: 32.0,
child: IntrinsicWidth(
child: AppFlowyPopover(
direction: PopoverDirection.bottomWithRightAligned,
constraints: const BoxConstraints(
maxWidth: 422,
),
margin: const EdgeInsets.all(16),
offset: const Offset(0, 8),
popupBuilder: (context) => BlocProvider.value(
value: shareBloc,
child: const ShareMenu(),
),
child: RoundedTextButton(
title: LocaleKeys.shareAction_buttonText.tr(),
padding: const EdgeInsets.symmetric(horizontal: 12.0),
fontSize: 14.0,
textColor: Theme.of(context).colorScheme.onPrimary,
),
),
),
);
},
),
),
);
@@ -0,0 +1,121 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:custom_sliding_segmented_control/custom_sliding_segmented_control.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'publish_tab.dart';
enum ShareMenuTab {
share,
publish,
exportAs;
static List<ShareMenuTab> supportedTabs = [
ShareMenuTab.share,
ShareMenuTab.publish,
ShareMenuTab.exportAs,
];
String get i18n {
switch (this) {
case ShareMenuTab.share:
return 'Share';
case ShareMenuTab.publish:
return LocaleKeys.shareAction_publish;
case ShareMenuTab.exportAs:
return 'Export as';
}
}
}
class ShareMenu extends StatefulWidget {
const ShareMenu({super.key});
@override
State<ShareMenu> createState() => _ShareMenuState();
}
class _ShareMenuState extends State<ShareMenu> {
ShareMenuTab selectedTab = ShareMenuTab.publish;
@override
Widget build(BuildContext context) {
final children = {
for (final tab in ShareMenuTab.supportedTabs)
tab: _Segment(
title: tab.i18n.tr(),
isSelected: selectedTab == tab,
),
};
return Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: 34,
child: CustomSlidingSegmentedControl<ShareMenuTab>(
initialValue: selectedTab,
curve: Curves.linear,
padding: 0,
fixedWidth: 128,
innerPadding: const EdgeInsets.all(3.0),
children: children,
decoration: BoxDecoration(
color: const Color(0xFFEEF0F3),
borderRadius: BorderRadius.circular(8),
),
thumbDecoration: BoxDecoration(
color: Colors.white,
boxShadow: const [
BoxShadow(
color: Color(0x141F2225),
blurRadius: 8,
offset: Offset(0, 2),
),
],
borderRadius: BorderRadius.circular(8),
),
onValueChanged: (v) {
setState(() {
selectedTab = v;
});
},
),
),
_buildTab(context),
],
);
}
Widget _buildTab(BuildContext context) {
switch (selectedTab) {
case ShareMenuTab.publish:
return const PublishTab();
default:
return const Center(
child: FlowyText('coming soon'),
);
}
}
}
class _Segment extends StatelessWidget {
const _Segment({
required this.title,
required this.isSelected,
});
final String title;
final bool isSelected;
@override
Widget build(BuildContext context) {
final textColor = isSelected ? null : Theme.of(context).hintColor;
return FlowyText(
title,
textAlign: TextAlign.center,
color: textColor,
);
}
}
@@ -19,6 +19,7 @@ import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
import 'package:appflowy_result/appflowy_result.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/uuid.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:protobuf/protobuf.dart';
@@ -257,6 +258,10 @@ class SpaceBloc extends Bloc<SpaceEvent, SpaceState> {
);
},
reset: (userProfile, workspaceId) async {
if (workspaceId == _workspaceId) {
return;
}
_reset(userProfile, workspaceId);
add(
@@ -321,6 +326,7 @@ class SpaceBloc extends Bloc<SpaceEvent, SpaceState> {
required String icon,
required String iconColor,
required SpacePermission permission,
String? viewId,
}) async {
final section = switch (permission) {
SpacePermission.publicToAll => ViewSectionPB.Public,
@@ -331,6 +337,7 @@ class SpaceBloc extends Bloc<SpaceEvent, SpaceState> {
name: name,
viewSection: section,
setAsCurrent: false,
viewId: viewId,
);
return await result.fold((space) async {
Log.info('Space created: $space');
@@ -476,56 +483,82 @@ class SpaceBloc extends Bloc<SpaceEvent, SpaceState> {
if (members.items.length == 1 || isOwner) {
// create a new public space and a new private space
// move all the views in the workspace to the new public/private space
final publicViews =
await _workspaceService.getPublicViews().getOrThrow();
// only migrate the public space if there are any public views
if (publicViews.isNotEmpty) {
final publicSpace = await _createSpace(
name: 'Shared',
icon: builtInSpaceIcons.first,
iconColor: builtInSpaceColors.first,
permission: SpacePermission.publicToAll,
);
if (publicSpace != null) {
for (final view in publicViews.reversed) {
if (view.isSpace) {
continue;
}
await ViewBackendService.moveViewV2(
viewId: view.id,
newParentId: publicSpace.id,
prevViewId: view.parentViewId,
);
}
}
}
}
// create a new private space
final privateViews =
await _workspaceService.getPrivateViews().getOrThrow();
// only migrate the private space if there are any private views
if (privateViews.isNotEmpty) {
final privateSpace = await _createSpace(
name: 'Private',
icon: builtInSpaceIcons.last,
iconColor: builtInSpaceColors.last,
permission: SpacePermission.private,
var publicViews = await _workspaceService.getPublicViews().getOrThrow();
final containsPublicSpace = publicViews.any(
(e) => e.isSpace && e.spacePermission == SpacePermission.publicToAll,
);
if (privateSpace != null) {
for (final view in privateViews.reversed) {
publicViews = publicViews.where((e) => !e.isSpace).toList();
// if there is already a public space, don't migrate the public space
// only migrate the public space if there are any public views
if (publicViews.isEmpty || containsPublicSpace) {
return true;
}
final viewId = fixedUuid(user.id.toInt(), UuidType.publicSpace);
final publicSpace = await _createSpace(
name: 'Shared',
icon: builtInSpaceIcons.first,
iconColor: builtInSpaceColors.first,
permission: SpacePermission.publicToAll,
viewId: viewId,
);
Log.info('migrating: created a new public space: ${publicSpace?.id}');
if (publicSpace != null) {
for (final view in publicViews.reversed) {
if (view.isSpace) {
continue;
}
await ViewBackendService.moveViewV2(
viewId: view.id,
newParentId: privateSpace.id,
prevViewId: view.parentViewId,
newParentId: publicSpace.id,
prevViewId: null,
);
Log.info(
'migrating: migrate ${view.name}(${view.id}) to public space(${publicSpace.id})',
);
}
}
}
// create a new private space
final viewId = fixedUuid(user.id.toInt(), UuidType.privateSpace);
var privateViews = await _workspaceService.getPrivateViews().getOrThrow();
// if there is already a private space, don't migrate the private space
final containsPrivateSpace = privateViews.any(
(e) => e.isSpace && e.spacePermission == SpacePermission.private,
);
privateViews = privateViews.where((e) => !e.isSpace).toList();
if (privateViews.isEmpty || containsPrivateSpace) {
return true;
}
// only migrate the private space if there are any private views
final privateSpace = await _createSpace(
name: 'Private',
icon: builtInSpaceIcons.last,
iconColor: builtInSpaceColors.last,
permission: SpacePermission.private,
viewId: viewId,
);
Log.info('migrating: created a new private space: ${privateSpace?.id}');
if (privateSpace != null) {
for (final view in privateViews.reversed) {
if (view.isSpace) {
continue;
}
await ViewBackendService.moveViewV2(
viewId: view.id,
newParentId: privateSpace.id,
prevViewId: null,
);
Log.info(
'migrating: migrate ${view.name}(${view.id}) to private space(${privateSpace.id})',
);
}
}
return true;
} catch (e) {
Log.error('migrate space error: $e');
@@ -538,14 +571,16 @@ class SpaceBloc extends Bloc<SpaceEvent, SpaceState> {
required List<ViewPB> publicViews,
required List<ViewPB> privateViews,
}) async {
final publicSpaces =
spaces.where((e) => e.spacePermission == SpacePermission.publicToAll);
final publicSpaces = spaces.where(
(e) => e.spacePermission == SpacePermission.publicToAll,
);
if (publicSpaces.isEmpty && publicViews.isNotEmpty) {
return true;
}
final privateSpaces =
spaces.where((e) => e.spacePermission == SpacePermission.private);
final privateSpaces = spaces.where(
(e) => e.spacePermission == SpacePermission.private,
);
if (privateSpaces.isEmpty && privateViews.isNotEmpty) {
return true;
}
@@ -38,6 +38,7 @@ class ViewBackendService {
/// If the index is null, the view will be added to the end of the list.
int? index,
ViewSectionPB? section,
final String? viewId,
}) {
final payload = CreateViewPayloadPB.create()
..parentViewId = parentViewId
@@ -63,6 +64,10 @@ class ViewBackendService {
payload.section = section;
}
if (viewId != null) {
payload.viewId = viewId;
}
return FolderEventCreateView(payload).send();
}
@@ -266,4 +271,42 @@ class ViewBackendService {
);
return FolderEventUpdateViewVisibilityStatus(payload).send();
}
static Future<FlowyResult<PublishInfoResponsePB, FlowyError>> getPublishInfo(
ViewPB view,
) async {
final payload = ViewIdPB()..value = view.id;
return FolderEventGetPublishInfo(payload).send();
}
static Future<FlowyResult<void, FlowyError>> publish(
ViewPB view, {
String? name,
}) async {
final payload = PublishViewParamsPB()..viewId = view.id;
if (name != null) {
payload.publishName = name;
}
return FolderEventPublishView(payload).send();
}
static Future<FlowyResult<void, FlowyError>> unpublish(
ViewPB view,
) async {
final payload = UnpublishViewsPayloadPB(viewIds: [view.id]);
return FolderEventUnpublishViews(payload).send();
}
static Future<FlowyResult<void, FlowyError>> setPublishNameSpace(
String name,
) async {
final payload = SetPublishNamespacePayloadPB()..newNamespace = name;
return FolderEventSetPublishNamespace(payload).send();
}
static Future<FlowyResult<PublishNamespacePB, FlowyError>>
getPublishNameSpace() async {
return FolderEventGetPublishNamespace().send();
}
}
@@ -18,6 +18,7 @@ class WorkspaceService {
int? index,
ViewLayoutPB? layout,
bool? setAsCurrent,
String? viewId,
}) {
final payload = CreateViewPayloadPB.create()
..parentViewId = workspaceId
@@ -37,6 +38,10 @@ class WorkspaceService {
payload.setAsCurrent = setAsCurrent;
}
if (viewId != null) {
payload.viewId = viewId;
}
return FolderEventCreateView(payload).send();
}
@@ -1,5 +1,19 @@
import 'package:uuid/data.dart';
import 'package:uuid/rng.dart';
import 'package:uuid/uuid.dart';
const _uuid = Uuid();
String uuid() {
return const Uuid().v4();
return _uuid.v4();
}
String fixedUuid(int seed, UuidType type) {
return _uuid.v4(config: V4Options(null, MathRNG(seed: seed + type.index)));
}
enum UuidType {
// 0.6.0
publicSpace,
privateSpace,
}
@@ -13,6 +13,7 @@ class RoundedTextButton extends StatelessWidget {
final Color? hoverColor;
final Color? textColor;
final double? fontSize;
final FontWeight? fontWeight;
final EdgeInsets padding;
const RoundedTextButton({
@@ -27,6 +28,7 @@ class RoundedTextButton extends StatelessWidget {
this.hoverColor,
this.textColor,
this.fontSize,
this.fontWeight,
this.padding = const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
});
@@ -42,6 +44,7 @@ class RoundedTextButton extends StatelessWidget {
child: SizedBox.expand(
child: FlowyTextButton(
title ?? '',
fontWeight: fontWeight,
onPressed: onPressed,
fontSize: fontSize,
mainAxisAlignment: MainAxisAlignment.center,
+10 -2
View File
@@ -394,6 +394,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.0"
custom_sliding_segmented_control:
dependency: "direct main"
description:
name: custom_sliding_segmented_control
sha256: "53c3e931c3ae1f696085d1ec70ac8e934da836595a9b7d9b88fdd0fcbf2a5574"
url: "https://pub.dev"
source: hosted
version: "1.8.3"
dart_style:
dependency: transitive
description:
@@ -2174,10 +2182,10 @@ packages:
dependency: "direct overridden"
description:
name: uuid
sha256: cd210a09f7c18cbe5a02511718e0334de6559871052c90a90c0cca46a4aa81c8
sha256: "814e9e88f21a176ae1359149021870e87f7cddaf633ab678a5d2b0bff7fd1ba8"
url: "https://pub.dev"
source: hosted
version: "4.3.3"
version: "4.4.0"
value_layout_builder:
dependency: transitive
description:
+3 -2
View File
@@ -15,7 +15,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
version: 0.6.0
version: 0.6.1
environment:
flutter: ">=3.22.0"
@@ -149,6 +149,7 @@ dependencies:
bitsdojo_window: ^0.1.6
flutter_highlight: ^0.7.0
custom_sliding_segmented_control: ^1.8.3
dev_dependencies:
flutter_lints: ^3.0.1
@@ -199,7 +200,7 @@ dependency_overrides:
ref: e44458d
path: sheet
uuid: ^4.1.0
uuid: ^4.4.0
flutter_cache_manager:
git:
+1 -1
View File
@@ -52,7 +52,7 @@ collab-user = { version = "0.2" }
# Run the script:
# scripts/tool/update_client_api_rev.sh new_rev_id
# ⚠️⚠️⚠️️
client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "6262816043efeede8823d7a7ea252083adf407e9" }
client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "fa7565d9de2a5e65e3886c8230588d58140637c5" }
[dependencies]
serde_json.workspace = true
+1 -1
View File
@@ -54,7 +54,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 = "6262816043efeede8823d7a7ea252083adf407e9" }
client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "fa7565d9de2a5e65e3886c8230588d58140637c5" }
[profile.dev]
opt-level = 0
+1 -1
View File
@@ -5,7 +5,7 @@ WORKDIR /app
RUN apt-get update && \
apt-get install -y nginx
RUN bun install cheerio pino axios pino-pretty
RUN bun install cheerio pino pino-pretty
COPY . .
@@ -1,11 +0,0 @@
{
"id": "9eebea03-3ed5-4298-86b2-a7f77856d48b",
"name": "workspace",
"icon": "",
"owner": {
"id": 0,
"name": "system"
},
"type": 0,
"workspaceDatabaseId": "375874be-7a4f-4b7c-8b89-1dc9a39838f4"
}
@@ -1,80 +0,0 @@
[
{
"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"
]
}
]
@@ -1 +0,0 @@
{"data":{"state_vector":[6,248,208,217,159,7,16,226,250,246,177,3,17,211,142,141,147,5,8,196,148,203,38,26,230,150,209,139,14,193,6,167,238,246,72,86],"doc_state":[6,177,5,230,150,209,139,14,0,0,2,0,1,0,5,39,0,196,148,203,38,4,6,122,45,72,76,78,68,2,4,0,230,150,209,139,14,8,4,49,49,49,49,39,0,196,148,203,38,1,6,54,70,57,105,75,107,1,40,0,230,150,209,139,14,13,2,105,100,1,119,6,54,70,57,105,75,107,40,0,230,150,209,139,14,13,2,116,121,1,119,7,104,101,97,100,105,110,103,40,0,230,150,209,139,14,13,6,112,97,114,101,110,116,1,119,10,74,118,87,74,108,105,53,79,117,84,40,0,230,150,209,139,14,13,8,99,104,105,108,100,114,101,110,1,119,6,97,118,105,69,115,45,33,0,230,150,209,139,14,13,4,100,97,116,97,1,40,0,230,150,209,139,14,13,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,230,150,209,139,14,13,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,196,148,203,38,3,6,97,118,105,69,115,45,0,72,196,148,203,38,24,1,119,6,54,70,57,105,75,107,39,0,196,148,203,38,4,6,69,115,57,50,84,74,2,33,0,196,148,203,38,1,6,98,71,84,69,74,116,1,0,7,33,0,196,148,203,38,3,6,87,56,66,102,66,110,1,129,196,148,203,38,24,1,168,230,150,209,139,14,18,1,119,39,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,49,49,49,49,34,125,93,44,34,108,101,118,101,108,34,58,50,125,4,0,230,150,209,139,14,23,1,35,0,1,132,230,150,209,139,14,35,1,35,0,1,132,230,150,209,139,14,37,1,35,0,1,132,230,150,209,139,14,39,1,35,0,1,39,0,196,148,203,38,4,6,56,76,76,113,48,56,2,39,0,196,148,203,38,1,6,56,70,105,117,112,98,1,40,0,230,150,209,139,14,44,2,105,100,1,119,6,56,70,105,117,112,98,40,0,230,150,209,139,14,44,2,116,121,1,119,7,104,101,97,100,105,110,103,40,0,230,150,209,139,14,44,6,112,97,114,101,110,116,1,119,10,74,118,87,74,108,105,53,79,117,84,40,0,230,150,209,139,14,44,8,99,104,105,108,100,114,101,110,1,119,6,49,66,67,68,107,53,33,0,230,150,209,139,14,44,4,100,97,116,97,1,40,0,230,150,209,139,14,44,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,230,150,209,139,14,44,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,196,148,203,38,3,6,49,66,67,68,107,53,0,200,196,148,203,38,24,230,150,209,139,14,33,1,119,6,56,70,105,117,112,98,4,0,230,150,209,139,14,43,1,50,161,230,150,209,139,14,49,1,132,230,150,209,139,14,54,1,50,161,230,150,209,139,14,55,1,132,230,150,209,139,14,56,1,50,161,230,150,209,139,14,57,1,132,230,150,209,139,14,58,1,50,161,230,150,209,139,14,59,1,39,0,196,148,203,38,4,6,103,76,57,104,70,45,2,39,0,196,148,203,38,1,6,72,99,71,82,109,56,1,40,0,230,150,209,139,14,63,2,105,100,1,119,6,72,99,71,82,109,56,40,0,230,150,209,139,14,63,2,116,121,1,119,9,112,97,114,97,103,114,97,112,104,40,0,230,150,209,139,14,63,6,112,97,114,101,110,116,1,119,10,74,118,87,74,108,105,53,79,117,84,40,0,230,150,209,139,14,63,8,99,104,105,108,100,114,101,110,1,119,6,50,114,117,49,72,109,33,0,230,150,209,139,14,63,4,100,97,116,97,1,40,0,230,150,209,139,14,63,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,230,150,209,139,14,63,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,196,148,203,38,3,6,50,114,117,49,72,109,0,136,230,150,209,139,14,33,1,119,6,72,99,71,82,109,56,168,230,150,209,139,14,61,1,119,39,123,34,108,101,118,101,108,34,58,52,44,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,50,50,50,50,34,125,93,125,1,0,230,150,209,139,14,62,1,161,230,150,209,139,14,68,1,129,230,150,209,139,14,74,1,161,230,150,209,139,14,75,1,129,230,150,209,139,14,76,2,161,230,150,209,139,14,77,1,129,230,150,209,139,14,79,1,161,230,150,209,139,14,80,1,129,230,150,209,139,14,81,1,161,230,150,209,139,14,82,1,68,230,150,209,139,14,74,6,228,189,160,229,165,189,168,230,150,209,139,14,84,1,119,31,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,228,189,160,229,165,189,34,125,93,125,39,0,196,148,203,38,4,6,113,111,68,56,109,111,2,33,0,196,148,203,38,1,6,113,118,66,98,82,83,1,0,7,33,0,196,148,203,38,3,6,118,76,97,51,57,90,1,129,230,150,209,139,14,72,1,1,0,230,150,209,139,14,88,1,0,1,129,230,150,209,139,14,99,1,0,1,129,230,150,209,139,14,101,1,0,4,132,230,150,209,139,14,103,1,91,0,1,132,230,150,209,139,14,108,1,93,0,1,39,0,196,148,203,38,4,6,69,114,99,104,55,72,2,39,0,196,148,203,38,1,6,51,111,95,70,117,67,1,40,0,230,150,209,139,14,113,2,105,100,1,119,6,51,111,95,70,117,67,40,0,230,150,209,139,14,113,2,116,121,1,119,9,116,111,100,111,95,108,105,115,116,40,0,230,150,209,139,14,113,6,112,97,114,101,110,116,1,119,10,74,118,87,74,108,105,53,79,117,84,40,0,230,150,209,139,14,113,8,99,104,105,108,100,114,101,110,1,119,6,68,56,109,75,57,97,33,0,230,150,209,139,14,113,4,100,97,116,97,1,40,0,230,150,209,139,14,113,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,230,150,209,139,14,113,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,196,148,203,38,3,6,68,56,109,75,57,97,0,200,230,150,209,139,14,72,230,150,209,139,14,98,1,119,6,51,111,95,70,117,67,4,0,230,150,209,139,14,112,1,49,161,230,150,209,139,14,118,1,132,230,150,209,139,14,123,1,50,161,230,150,209,139,14,Line truncated
@@ -1 +0,0 @@
{"data":{"state_vector":[160,1,128,252,161,128,4,33,130,180,254,251,6,18,135,232,133,203,9,15,135,240,136,178,10,45,137,226,192,199,6,20,138,202,240,189,2,134,1,139,240,196,145,14,138,2,139,152,215,249,10,34,141,216,158,150,1,3,142,130,192,134,11,3,143,184,153,180,6,6,140,228,230,243,1,2,145,190,137,224,10,2,145,144,146,185,5,12,140,152,206,145,6,165,1,135,166,246,235,6,3,147,206,229,235,1,3,137,164,190,210,1,39,152,158,185,230,10,7,153,130,203,161,6,97,152,252,186,192,1,2,154,244,246,165,8,198,1,156,148,170,169,10,16,157,240,144,231,2,3,158,156,181,152,10,49,158,182,250,251,9,15,160,192,253,131,5,3,161,178,132,150,11,101,159,156,204,250,6,27,163,236,177,169,4,12,164,188,201,172,1,3,158,184,218,165,3,38,162,238,198,212,7,3,170,140,240,234,9,31,171,204,155,217,8,3,171,142,166,254,1,6,173,252,148,184,13,64,176,154,159,227,1,20,178,162,190,217,10,3,179,252,154,44,3,180,230,210,212,13,7,182,172,247,194,5,3,183,226,184,158,8,8,187,220,199,239,8,70,195,242,227,194,8,3,196,154,250,183,6,62,197,254,154,201,10,3,200,142,208,241,4,157,1,202,160,246,212,1,6,203,184,221,173,11,37,206,220,129,131,4,21,207,228,238,162,8,50,209,250,203,254,15,3,210,228,153,221,12,3,211,202,217,232,12,7,211,166,203,229,4,195,1,214,168,149,214,3,15,219,220,239,171,8,10,225,248,138,176,2,49,226,212,179,248,2,15,229,154,128,197,12,24,234,182,182,157,9,8,235,178,165,206,5,72,234,156,130,211,12,12,241,130,161,205,7,12,244,226,228,149,2,30,245,220,194,52,39,247,200,243,247,14,100,248,136,168,181,1,3,248,210,237,129,13,2,250,198,166,187,7,2,248,196,187,185,10,31,252,218,241,167,14,3,255,140,248,220,6,3,128,211,179,216,2,10,133,159,138,205,12,131,1,135,167,156,250,14,16,135,193,208,135,7,16,141,245,194,142,11,7,141,205,220,149,4,3,143,131,148,152,6,15,141,171,170,217,4,2,145,159,164,217,14,3,149,129,169,191,12,16,149,249,242,175,4,31,149,189,189,215,8,3,149,161,132,184,14,221,2,154,243,157,196,14,12,154,193,208,134,10,9,155,165,205,152,11,3,154,235,215,240,4,13,155,159,180,195,15,6,157,207,243,216,6,2,161,239,241,154,13,106,162,159,252,196,11,3,164,155,139,169,7,35,165,139,157,171,15,103,164,203,250,235,13,7,167,131,133,162,9,11,166,201,221,141,13,72,169,197,188,221,3,3,170,255,211,105,38,166,203,155,46,6,173,187,245,170,14,46,174,151,139,93,179,2,175,225,172,150,8,11,175,147,217,214,1,33,177,161,136,243,11,10,178,203,205,182,4,1,175,205,156,228,6,10,180,205,189,133,13,20,181,175,219,209,12,10,182,143,233,195,4,111,177,219,160,167,7,4,184,201,188,172,10,3,184,231,170,67,6,186,197,166,179,15,7,187,173,214,176,15,3,188,171,136,250,8,21,188,237,223,145,6,26,190,139,191,155,1,2,191,157,147,233,9,32,193,249,142,142,4,17,198,189,216,175,6,23,200,205,214,172,10,30,201,129,238,197,4,84,201,191,159,147,14,17,200,203,236,184,2,35,200,159,185,206,9,8,205,149,231,236,11,68,213,161,242,209,13,35,214,139,213,136,8,66,213,255,156,145,1,2,219,227,140,137,6,34,221,147,167,147,15,177,2,222,205,223,235,7,31,223,209,193,147,11,81,227,209,197,253,2,14,229,153,197,202,7,241,6,231,139,244,188,8,13,232,207,157,148,2,2,233,165,139,246,14,43,233,247,183,159,1,4,235,225,184,133,10,3,234,153,236,158,4,73,234,187,164,181,1,24,231,189,134,196,8,77,239,199,189,146,3,24,240,149,229,225,6,15,241,155,213,233,1,52,240,253,240,229,1,79,243,239,182,181,13,30,236,229,225,232,8,116,246,185,174,192,6,90,248,153,216,10,3,251,189,220,155,14,3,252,163,130,200,6,30,253,205,145,137,11,10,252,171,209,175,15,4,255,255,147,249,10,3],"doc_state":[160,1,3,209,250,203,254,15,0,161,226,212,179,248,2,9,1,161,226,212,179,248,2,10,1,136,174,151,139,93,249,1,1,118,2,2,105,100,119,36,50,54,100,53,99,56,99,49,45,49,99,54,54,45,52,53,57,99,45,98,99,54,99,45,102,52,100,97,49,97,54,54,51,51,52,56,9,116,105,109,101,115,116,97,109,112,122,0,0,0,0,102,83,246,78,2,155,159,180,195,15,0,161,198,189,216,175,6,22,1,161,253,205,145,137,11,9,5,7,186,197,166,179,15,0,161,161,178,132,150,11,98,1,161,161,178,132,150,11,99,1,129,161,178,132,150,11,100,1,161,161,178,132,150,11,97,1,161,161,178,132,150,11,94,1,161,161,178,132,150,11,95,1,129,186,197,166,179,15,2,1,3,187,173,214,176,15,0,168,244,226,228,149,2,27,1,122,4,56,115,160,190,64,16,0,168,244,226,228,149,2,28,1,122,0,0,0,0,102,32,153,171,136,248,153,216,10,2,1,118,2,2,105,100,119,36,48,53,51,51,50,98,97,52,45,97,54,57,48,45,52,50,57,51,45,57,56,54,54,45,56,52,100,97,99,55,102,101,50,97,101,97,9,116,105,109,101,115,116,97,109,112,122,0,0,0,0,102,32,153,171,1,252,171,209,175,15,0,161,128,211,179,216,2,9,4,103,165,139,157,171,15,0,8,0,201,129,238,197,4,52,1,118,1,2,105,100,119,36,97,53,53,54,54,101,52,57,45,102,49,53,54,45,52,49,54,56,45,57,98,50,100,45,49,55,57,50,54,99,53,100,97,51,50,57,168,201,129,238,197,4,51,1,122,0,0,0,0,102,74,244,14,161,170,140,240,234,9,22,1,39,0,203,184,221,173,11,1,36,97,53,53,54,54,101,52,57,45,102,49,53,54,45,52,49,54,56,45,57,98,50,100,45,49,55,57,50,54,99,53,100,97,51,50,57,1,40,0,165,139,157,171,15,3,2,105,100,1,119,36,97,53,53,54,54,101,52,57,45,102,49,53,54,45,52,49,54,56,45,57,98,50,100,45,49,55,57,50,54,99,53,100,97,51,50,57,40,0,Line truncated
@@ -1,66 +0,0 @@
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MTI4Mjk2MjAsImlhdCI6MTcxMjgyNjAyMCwic3ViIjoiY2JmZjA2MGEtMTk2ZC00MTVhLWFhODAtNzU5YzAxODg2NDY2IiwiZW1haWwiOiJsdUBhcHBmbG93eS5pbyIsInBob25lIjoiIiwiYXBwX21ldGFkYXRhIjp7InByb3ZpZGVyIjoiZ29vZ2xlIiwicHJvdmlkZXJzIjpbImdvb2dsZSJdfSwidXNlcl9tZXRhZGF0YSI6eyJhdmF0YXJfdXJsIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tL2EvQUNnOG9jTEhabVZBczRTb0ZlVFFuWG5CU2JiNTBBVXF0YktHNWx5MGllVHZCSklYZ1o3UmdRPXM5Ni1jIiwiY3VzdG9tX2NsYWltcyI6eyJoZCI6ImFwcGZsb3d5LmlvIn0sImVtYWlsIjoibHVAYXBwZmxvd3kuaW8iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZnVsbF9uYW1lIjoiTHUgSGUiLCJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJuYW1lIjoiTHUgSGUiLCJwaG9uZV92ZXJpZmllZCI6ZmFsc2UsInBpY3R1cmUiOiJodHRwczovL2xoMy5nb29nbGV1c2VyY29udGVudC5jb20vYS9BQ2c4b2NMSFptVkFzNFNvRmVUUW5YbkJTYmI1MEFVcXRiS0c1bHkwaWVUdkJKSVhnWjdSZ1E9czk2LWMiLCJwcm92aWRlcl9pZCI6IjEwMTE2OTI1MDgyOTU1NDAyODM4MSIsInN1YiI6IjEwMTE2OTI1MDgyOTU1NDAyODM4MSJ9LCJyb2xlIjoiIiwiYWFsIjoiYWFsMSIsImFtciI6W3sibWV0aG9kIjoicGFzc3dvcmQiLCJ0aW1lc3RhbXAiOjE3MTI4MjYwMjB9XSwic2Vzc2lvbl9pZCI6ImJmMzE5OTRlLTQwMTgtNDhjMS05Yzc0LWVmYzkyMGNjOWQ0NSJ9.QeTrRhsnBjBL1GUS3TIWOgU1SPM6RcaWwxZdMVfcFBU",
"token_type": "bearer",
"expires_in": 3600,
"expires_at": 4869016461,
"refresh_token": "71vp1jJnSAVluZKaXkhG1A",
"user": {
"id": "cbff060a-196d-415a-aa80-759c01886466",
"aud": "",
"role": "",
"email": "lu@appflowy.io",
"email_confirmed_at": "2024-03-13T10:49:53.165361Z",
"phone": "",
"confirmed_at": "2024-03-13T10:49:53.165361Z",
"last_sign_in_at": "2024-04-11T09:00:20.547468985Z",
"app_metadata": {
"provider": "google",
"providers": [
"google"
]
},
"user_metadata": {
"avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocLHZmVAs4SoFeTQnXnBSbb50AUqtbKG5ly0ieTvBJIXgZ7RgQ=s96-c",
"custom_claims": {
"hd": "appflowy.io"
},
"email": "lu@appflowy.io",
"email_verified": true,
"full_name": "Lu He",
"iss": "https://accounts.google.com",
"name": "Lu He",
"phone_verified": false,
"picture": "https://lh3.googleusercontent.com/a/ACg8ocLHZmVAs4SoFeTQnXnBSbb50AUqtbKG5ly0ieTvBJIXgZ7RgQ=s96-c",
"provider_id": "101169250829554028381",
"sub": "101169250829554028381"
},
"identities": [
{
"identity_id": "e4cf8b69-7f80-42e9-aed2-e25132ad0178",
"id": "101169250829554028381",
"user_id": "cbff060a-196d-415a-aa80-759c01886466",
"identity_data": {
"avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocLHZmVAs4SoFeTQnXnBSbb50AUqtbKG5ly0ieTvBJIXgZ7RgQ=s96-c",
"custom_claims": {
"hd": "appflowy.io"
},
"email": "lu@appflowy.io",
"email_verified": true,
"full_name": "Lu He",
"iss": "https://accounts.google.com",
"name": "Lu He",
"phone_verified": false,
"picture": "https://lh3.googleusercontent.com/a/ACg8ocLHZmVAs4SoFeTQnXnBSbb50AUqtbKG5ly0ieTvBJIXgZ7RgQ=s96-c",
"provider_id": "101169250829554028381",
"sub": "101169250829554028381"
},
"provider": "google",
"last_sign_in_at": "2024-03-13T07:22:43.110504Z",
"created_at": "2024-03-13T07:22:43.110543Z",
"updated_at": "2024-04-04T06:15:14.03093Z"
}
],
"created_at": "2024-03-13T07:22:43.102586Z",
"updated_at": "2024-04-11T09:00:20.551485Z"
}
}
@@ -1,17 +0,0 @@
{
"data": {
"uid": 304120109071339520,
"uuid": "cbff060a-196d-415a-aa80-759c01886466",
"email": "lu@appflowy.io",
"password": "",
"name": "Kilu",
"metadata": {
"icon_url": "🇽🇰"
},
"encryption_sign": null,
"latest_workspace_id": "fcb503f9-9287-4de4-8de0-ea191e680968",
"updated_at": 1710421586
},
"code": 0,
"message": "Operation completed successfully."
}
@@ -1 +0,0 @@
{"data":{"user_profile":{"uid":304120109071339520,"uuid":"cbff060a-196d-415a-aa80-759c01886466","email":"lu@appflowy.io","password":"","name":"Kilu","metadata":{"icon_url":"🇽🇰"},"encryption_sign":null,"latest_workspace_id":"9eebea03-3ed5-4298-86b2-a7f77856d48b","updated_at":1715847453},"visiting_workspace":{"workspace_id":"9eebea03-3ed5-4298-86b2-a7f77856d48b","database_storage_id":"375874be-7a4f-4b7c-8b89-1dc9a39838f4","owner_uid":304120109071339520,"owner_name":"Kilu","workspace_type":0,"workspace_name":"Kilu Works","created_at":"2024-03-13T07:23:10.275174Z","icon":"😆"},"workspaces":[{"workspace_id":"81570fa8-8be9-4b2d-9f1c-1ef4f34079a8","database_storage_id":"6c1f1a2c-e8d5-4bc2-917f-495bce862abb","owner_uid":311828434584080384,"owner_name":"Zack Zi Xiang Fu","workspace_type":0,"workspace_name":"My Workspace","created_at":"2024-04-03T13:53:18.295918Z","icon":""},{"workspace_id":"fcb503f9-9287-4de4-8de0-ea191e680968","database_storage_id":"ae1b82a5-2b93-45c7-901a-f9357c544534","owner_uid":276169796100296704,"owner_name":"Annie Anqi Wang","workspace_type":0,"workspace_name":"AppFlowy Test","created_at":"2023-12-27T04:18:36.372013Z","icon":""},{"workspace_id":"9eebea03-3ed5-4298-86b2-a7f77856d48b","database_storage_id":"375874be-7a4f-4b7c-8b89-1dc9a39838f4","owner_uid":304120109071339520,"owner_name":"Kilu","workspace_type":0,"workspace_name":"Kilu Works","created_at":"2024-03-13T07:23:10.275174Z","icon":"😆"}]},"code":0,"message":"Operation completed successfully."}
@@ -1,6 +0,0 @@
{
"code": 0,
"data": {
"is_new": false
}
}
@@ -25,96 +25,9 @@
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
//
import { YDoc } from '@/application/collab.type';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import { JSDatabaseService } from '@/application/services/js-services/database.service';
import { JSDocumentService } from '@/application/services/js-services/document.service';
import { applyYDoc } from '@/application/ydoc/apply';
import * as Y from 'yjs';
Cypress.Commands.add('mockAPI', () => {
cy.fixture('sign_in_success').then((json) => {
cy.intercept('GET', `/api/user/verify/${json.access_token}`, {
fixture: 'verify_token',
}).as('verifyToken');
cy.intercept('POST', '/gotrue/token?grant_type=password', json).as('loginSuccess');
cy.intercept('POST', '/gotrue/token?grant_type=refresh_token', json).as('refreshToken');
});
cy.intercept('GET', '/api/user/profile', { fixture: 'user' }).as('getUserProfile');
cy.intercept('GET', '/api/user/workspace', { fixture: 'user_workspace' }).as('getUserWorkspace');
// Mock the API
});
// Example use:
// beforeEach(() => {
// cy.mockAPI();
// });
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
Cypress.Commands.add('mockCurrentWorkspace', () => {
cy.fixture('current_workspace').then((workspace) => {
cy.stub(JSDatabaseService.prototype, 'currentWorkspace').resolves(workspace);
});
});
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
Cypress.Commands.add('mockGetWorkspaceDatabases', () => {
cy.fixture('database/databases').then((databases) => {
cy.stub(JSDatabaseService.prototype, 'getWorkspaceDatabases').resolves(databases);
});
});
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
Cypress.Commands.add('mockDatabase', () => {
cy.mockCurrentWorkspace();
cy.mockGetWorkspaceDatabases();
const ids = [
'4c658817-20db-4f56-b7f9-0637a22dfeb6',
'ce267d12-3b61-4ebb-bb03-d65272f5f817',
'ad7dc45b-44b5-498f-bfa2-0f43bf05cc0d',
];
const mockOpenDatabase = cy.stub(JSDatabaseService.prototype, 'openDatabase');
ids.forEach((id) => {
cy.fixture(`database/${id}`).then((database) => {
cy.fixture(`database/rows/${id}`).then((rows) => {
const doc = new Y.Doc();
const rootRowsDoc = new Y.Doc();
const rowsFolder: Y.Map<YDoc> = rootRowsDoc.getMap();
const databaseState = new Uint8Array(database.data.doc_state);
applyYDoc(doc, databaseState);
Object.keys(rows).forEach((key) => {
const data = rows[key];
const rowDoc = new Y.Doc();
applyYDoc(rowDoc, new Uint8Array(data));
rowsFolder.set(key, rowDoc);
});
mockOpenDatabase.withArgs(id).resolves({
databaseDoc: doc,
rows: rowsFolder,
});
});
});
});
});
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
Cypress.Commands.add('mockDocument', (id: string) => {
cy.fixture(`document/${id}`).then((subDocument) => {
const doc = new Y.Doc();
const state = new Uint8Array(subDocument.data.doc_state);
applyYDoc(doc, state);
cy.stub(JSDocumentService.prototype, 'openDocument').withArgs(id).resolves(doc);
});
});
export {};
+7 -1
View File
@@ -24,7 +24,7 @@
"coverage": "pnpm run test:unit && pnpm run test:components"
},
"dependencies": {
"@appflowyinc/client-api-wasm": "0.0.3",
"@appflowyinc/client-api-wasm": "0.0.4",
"@atlaskit/primitives": "^5.5.3",
"@emoji-mart/data": "^1.1.2",
"@emoji-mart/react": "^1.1.1",
@@ -43,6 +43,8 @@
"colorthief": "^2.4.0",
"dayjs": "^1.11.9",
"decimal.js": "^10.4.3",
"dexie": "^4.0.7",
"dexie-react-hooks": "^1.1.7",
"emoji-mart": "^5.5.2",
"emoji-regex": "^10.2.1",
"events": "^3.3.0",
@@ -56,6 +58,7 @@
"katex": "^0.16.7",
"lodash-es": "^4.17.21",
"nanoid": "^4.0.0",
"notistack": "^3.0.1",
"numeral": "^2.0.6",
"prismjs": "^1.29.0",
"protoc-gen-ts": "0.8.7",
@@ -136,6 +139,7 @@
"autoprefixer": "^10.4.13",
"babel-jest": "^29.6.2",
"chalk": "^4.1.2",
"cheerio": "1.0.0-rc.12",
"cross-env": "^7.0.3",
"cypress": "^13.7.2",
"eslint": "^8.57.0",
@@ -145,6 +149,8 @@
"istanbul-lib-coverage": "^3.2.2",
"jest-environment-jsdom": "^29.6.2",
"nyc": "^15.1.0",
"pino": "^9.2.0",
"pino-pretty": "^11.2.1",
"postcss": "^8.4.21",
"prettier": "2.8.4",
"prettier-plugin-tailwindcss": "^0.2.2",
+256 -8
View File
@@ -1,9 +1,13 @@
lockfileVersion: '6.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
dependencies:
'@appflowyinc/client-api-wasm':
specifier: 0.0.3
version: 0.0.3
specifier: 0.0.4
version: 0.0.4
'@atlaskit/primitives':
specifier: ^5.5.3
version: 5.7.0(@types/react@18.2.66)(react@18.2.0)
@@ -58,6 +62,12 @@ dependencies:
decimal.js:
specifier: ^10.4.3
version: 10.4.3
dexie:
specifier: ^4.0.7
version: 4.0.7
dexie-react-hooks:
specifier: ^1.1.7
version: 1.1.7(@types/react@18.2.66)(dexie@4.0.7)(react@18.2.0)
emoji-mart:
specifier: ^5.5.2
version: 5.6.0
@@ -97,6 +107,9 @@ dependencies:
nanoid:
specifier: ^4.0.0
version: 4.0.2
notistack:
specifier: ^3.0.1
version: 3.0.1(csstype@3.1.3)(react-dom@18.2.0)(react@18.2.0)
numeral:
specifier: ^2.0.6
version: 2.0.6
@@ -333,6 +346,9 @@ devDependencies:
chalk:
specifier: ^4.1.2
version: 4.1.2
cheerio:
specifier: 1.0.0-rc.12
version: 1.0.0-rc.12
cross-env:
specifier: ^7.0.3
version: 7.0.3
@@ -360,6 +376,12 @@ devDependencies:
nyc:
specifier: ^15.1.0
version: 15.1.0
pino:
specifier: ^9.2.0
version: 9.2.0
pino-pretty:
specifier: ^11.2.1
version: 11.2.1
postcss:
specifier: ^8.4.21
version: 8.4.21
@@ -429,8 +451,8 @@ packages:
'@jridgewell/gen-mapping': 0.3.5
'@jridgewell/trace-mapping': 0.3.25
/@appflowyinc/client-api-wasm@0.0.3:
resolution: {integrity: sha512-ARjLhiDZ8MiZ9egWDbAX9VAdXXS30av+InCPLrS/iqCMYrhuuU9rxS9jQeNEB7jucFrj158gBRusimFN7P/lyw==}
/@appflowyinc/client-api-wasm@0.0.4:
resolution: {integrity: sha512-R8nm811vmh4oJU7LDFV7TJP89Y+WZKYrwpR1xrfL9m+PtyA/c21HvNJrvT+NssHfuz+IKj4L1cH2uXQcggnybA==}
dev: false
/@atlaskit/analytics-next-stable-react-context@1.0.1(react@18.2.0):
@@ -4580,6 +4602,13 @@ packages:
deprecated: Use your platform's native atob() and btoa() methods instead
dev: true
/abort-controller@3.0.0:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
engines: {node: '>=6.5'}
dependencies:
event-target-shim: 5.0.1
dev: true
/acorn-globals@7.0.1:
resolution: {integrity: sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==}
dependencies:
@@ -4886,6 +4915,11 @@ packages:
engines: {node: '>= 4.0.0'}
dev: true
/atomic-sleep@1.0.0:
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
engines: {node: '>=8.0.0'}
dev: true
/autoprefixer@10.4.13(postcss@8.4.21):
resolution: {integrity: sha512-49vKpMqcZYsJjwotvt4+h/BCjJVnhGwcLpDt5xkcaOG3eLrG/HUYLagrihYsQ+qrIBgIzX1Rw7a6L8I/ZA1Atg==}
engines: {node: ^10 || ^12 || >=14}
@@ -5180,6 +5214,13 @@ packages:
ieee754: 1.2.1
dev: true
/buffer@6.0.3:
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
dev: true
/cachedir@2.4.0:
resolution: {integrity: sha512-9EtFOZR8g22CL7BWjJ9BUx1+A/djkofnyW3aOXZORNW2kxoUpx2h+uN2cOqwPmFhnpVmxg+KW2OjOSgChTEvsQ==}
engines: {node: '>=6'}
@@ -5289,6 +5330,30 @@ packages:
engines: {node: '>= 0.8.0'}
dev: true
/cheerio-select@2.1.0:
resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==}
dependencies:
boolbase: 1.0.0
css-select: 5.1.0
css-what: 6.1.0
domelementtype: 2.3.0
domhandler: 5.0.3
domutils: 3.1.0
dev: true
/cheerio@1.0.0-rc.12:
resolution: {integrity: sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==}
engines: {node: '>= 6'}
dependencies:
cheerio-select: 2.1.0
dom-serializer: 2.0.0
domhandler: 5.0.3
domutils: 3.1.0
htmlparser2: 8.0.2
parse5: 7.1.2
parse5-htmlparser2-tree-adapter: 7.0.0
dev: true
/chokidar@3.6.0:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'}
@@ -5739,6 +5804,10 @@ packages:
dependencies:
'@babel/runtime': 7.24.1
/dateformat@4.6.3:
resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==}
dev: true
/dayjs@1.11.10:
resolution: {integrity: sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==}
dev: true
@@ -5860,6 +5929,22 @@ packages:
minimist: 1.2.8
dev: true
/dexie-react-hooks@1.1.7(@types/react@18.2.66)(dexie@4.0.7)(react@18.2.0):
resolution: {integrity: sha512-Lwv5W0Hk+uOW3kGnsU9GZoR1er1B7WQ5DSdonoNG+focTNeJbHW6vi6nBoX534VKI3/uwHebYzSw1fwY6a7mTw==}
peerDependencies:
'@types/react': '>=16'
dexie: ^3.2 || ^4.0.1-alpha
react: '>=16'
dependencies:
'@types/react': 18.2.66
dexie: 4.0.7
react: 18.2.0
dev: false
/dexie@4.0.7:
resolution: {integrity: sha512-M+Lo6rk4pekIfrc2T0o2tvVJwL6EAAM/B78DNfb8aaxFVoI1f8/rz5KTxuAnApkwqTSuxx7T5t0RKH7qprapGg==}
dev: false
/didyoumean@1.2.2:
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
dev: true
@@ -6372,6 +6457,11 @@ packages:
engines: {node: '>=0.10.0'}
dev: true
/event-target-shim@5.0.1:
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
engines: {node: '>=6'}
dev: true
/eventemitter2@6.4.7:
resolution: {integrity: sha512-tYUSVOGeQPKt/eC1ABfhHy5Xd96N3oIijJvN3O9+TsC28T5V9yX9oEfEK5faP0EFSNVOG97qtAS68GBrQB2hDg==}
dev: true
@@ -6455,6 +6545,10 @@ packages:
resolution: {integrity: sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==}
engines: {'0': node >=0.6.0}
/fast-copy@3.0.2:
resolution: {integrity: sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==}
dev: true
/fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@@ -6492,6 +6586,15 @@ packages:
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
dev: true
/fast-redact@3.5.0:
resolution: {integrity: sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==}
engines: {node: '>=6'}
dev: true
/fast-safe-stringify@2.1.1:
resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==}
dev: true
/fastq@1.17.1:
resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==}
dependencies:
@@ -6961,6 +7064,10 @@ packages:
tslib: 2.6.2
dev: true
/help-me@5.0.0:
resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==}
dev: true
/hoist-non-react-statics@3.3.2:
resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
dependencies:
@@ -6983,6 +7090,15 @@ packages:
void-elements: 3.1.0
dev: false
/htmlparser2@8.0.2:
resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
dependencies:
domelementtype: 2.3.0
domhandler: 5.0.3
domutils: 3.1.0
entities: 4.5.0
dev: true
/http-proxy-agent@5.0.0:
resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==}
engines: {node: '>= 6'}
@@ -7881,6 +7997,11 @@ packages:
- supports-color
- ts-node
/joycon@3.1.1:
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
engines: {node: '>=10'}
dev: true
/jpeg-js@0.4.4:
resolution: {integrity: sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==}
dev: false
@@ -8440,6 +8561,21 @@ packages:
engines: {node: '>=0.10.0'}
dev: true
/notistack@3.0.1(csstype@3.1.3)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-ntVZXXgSQH5WYfyU+3HfcXuKaapzAJ8fBLQ/G618rn3yvSzEbnOB8ZSOwhX+dAORy/lw+GC2N061JA0+gYWTVA==}
engines: {node: '>=12.0.0', npm: '>=6.0.0'}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
dependencies:
clsx: 1.2.1
goober: 2.1.14(csstype@3.1.3)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
transitivePeerDependencies:
- csstype
dev: false
/npm-run-path@4.0.1:
resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
engines: {node: '>=8'}
@@ -8576,6 +8712,11 @@ packages:
resolution: {integrity: sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==}
dev: false
/on-exit-leak-free@2.1.2:
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
engines: {node: '>=14.0.0'}
dev: true
/once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
dependencies:
@@ -8721,6 +8862,13 @@ packages:
json-parse-even-better-errors: 2.3.1
lines-and-columns: 1.2.4
/parse5-htmlparser2-tree-adapter@7.0.0:
resolution: {integrity: sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==}
dependencies:
domhandler: 5.0.3
parse5: 7.1.2
dev: true
/parse5@7.1.2:
resolution: {integrity: sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==}
dependencies:
@@ -8810,6 +8958,54 @@ packages:
engines: {node: '>=6'}
dev: true
/pino-abstract-transport@1.2.0:
resolution: {integrity: sha512-Guhh8EZfPCfH+PMXAb6rKOjGQEoy0xlAIn+irODG5kgfYV+BQ0rGYYWTIel3P5mmyXqkYkPmdIkywsn6QKUR1Q==}
dependencies:
readable-stream: 4.5.2
split2: 4.2.0
dev: true
/pino-pretty@11.2.1:
resolution: {integrity: sha512-O05NuD9tkRasFRWVaF/uHLOvoRDFD7tb5VMertr78rbsYFjYp48Vg3477EshVAF5eZaEw+OpDl/tu+B0R5o+7g==}
hasBin: true
dependencies:
colorette: 2.0.20
dateformat: 4.6.3
fast-copy: 3.0.2
fast-safe-stringify: 2.1.1
help-me: 5.0.0
joycon: 3.1.1
minimist: 1.2.8
on-exit-leak-free: 2.1.2
pino-abstract-transport: 1.2.0
pump: 3.0.0
readable-stream: 4.5.2
secure-json-parse: 2.7.0
sonic-boom: 4.0.1
strip-json-comments: 3.1.1
dev: true
/pino-std-serializers@7.0.0:
resolution: {integrity: sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==}
dev: true
/pino@9.2.0:
resolution: {integrity: sha512-g3/hpwfujK5a4oVbaefoJxezLzsDgLcNJeITvC6yrfwYeT9la+edCK42j5QpEQSQCZgTKapXvnQIdgZwvRaZug==}
hasBin: true
dependencies:
atomic-sleep: 1.0.0
fast-redact: 3.5.0
on-exit-leak-free: 2.1.2
pino-abstract-transport: 1.2.0
pino-std-serializers: 7.0.0
process-warning: 3.0.0
quick-format-unescaped: 4.0.4
real-require: 0.2.0
safe-stable-stringify: 2.4.3
sonic-boom: 4.0.1
thread-stream: 3.1.0
dev: true
/pirates@4.0.6:
resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==}
engines: {node: '>= 6'}
@@ -9027,6 +9223,10 @@ packages:
fromentries: 1.3.2
dev: true
/process-warning@3.0.0:
resolution: {integrity: sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==}
dev: true
/process@0.11.10:
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
engines: {node: '>= 0.6.0'}
@@ -9115,6 +9315,10 @@ packages:
resolution: {integrity: sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==}
dev: true
/quick-format-unescaped@4.0.4:
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
dev: true
/quick-lru@5.1.1:
resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==}
engines: {node: '>=10'}
@@ -9635,12 +9839,28 @@ packages:
util-deprecate: 1.0.2
dev: true
/readable-stream@4.5.2:
resolution: {integrity: sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
dependencies:
abort-controller: 3.0.0
buffer: 6.0.3
events: 3.3.0
process: 0.11.10
string_decoder: 1.3.0
dev: true
/readdirp@3.6.0:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'}
dependencies:
picomatch: 2.3.1
/real-require@0.2.0:
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
engines: {node: '>= 12.13.0'}
dev: true
/redux-thunk@3.1.0(redux@5.0.1):
resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
peerDependencies:
@@ -9924,6 +10144,11 @@ packages:
is-regex: 1.1.4
dev: true
/safe-stable-stringify@2.4.3:
resolution: {integrity: sha512-e2bDA2WJT0wxseVd4lsDP4+3ONX6HpMXQa1ZhFQ7SU+GjvORCmShbCMltrtIDfkYhVHrOcPtj+KhmDBdPdZD1g==}
engines: {node: '>=10'}
dev: true
/safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
@@ -9973,6 +10198,10 @@ packages:
compute-scroll-into-view: 3.1.0
dev: false
/secure-json-parse@2.7.0:
resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==}
dev: true
/semver@5.7.2:
resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==}
hasBin: true
@@ -10151,6 +10380,12 @@ packages:
tslib: 2.6.2
dev: true
/sonic-boom@4.0.1:
resolution: {integrity: sha512-hTSD/6JMLyT4r9zeof6UtuBDpjJ9sO08/nmS5djaA9eozT9oOlNdpXSnzcgj4FTqpk3nkLrs61l4gip9r1HCrQ==}
dependencies:
atomic-sleep: 1.0.0
dev: true
/source-map-js@1.2.0:
resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==}
engines: {node: '>=0.10.0'}
@@ -10194,6 +10429,11 @@ packages:
which: 2.0.2
dev: true
/split2@4.2.0:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'}
dev: true
/sprintf-js@1.0.3:
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
@@ -10306,6 +10546,12 @@ packages:
safe-buffer: 5.1.2
dev: true
/string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
dependencies:
safe-buffer: 5.2.1
dev: true
/strip-ansi@6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
@@ -10499,6 +10745,12 @@ packages:
resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
dev: true
/thread-stream@3.1.0:
resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==}
dependencies:
real-require: 0.2.0
dev: true
/throttleit@1.0.1:
resolution: {integrity: sha512-vDZpf9Chs9mAdfY046mcPt8fg5QSZr37hEH4TXYBnDF+izxgrbRGUAAaBvIk/fJm9aOFCGFd1EsNg5AZCbnQCQ==}
dev: true
@@ -11414,7 +11666,3 @@ packages:
resolution: {integrity: sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==}
engines: {node: '>=12.20'}
dev: true
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
+51 -16
View File
@@ -2,8 +2,7 @@ const path = require('path');
const fs = require('fs');
const pino = require('pino');
const cheerio = require('cheerio');
const axios = require('axios');
const { fetch } = require('bun');
const distDir = path.join(__dirname, 'dist');
const indexPath = path.join(distDir, 'index.html');
@@ -38,38 +37,74 @@ const logRequestTimer = (req) => {
};
const fetchMetaData = async (url) => {
logger.info(`Fetching meta data from ${url}`);
try {
const response = await axios.get(url);
return response.data;
const response = await fetch(url, {
verbose: true,
});
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
logger.error('Error fetching meta data', error);
logger.error(`Error fetching meta data ${error}`);
return null;
}
};
const BASE_URL = process.env.AF_BASE_URL || 'https://test.appflowy.cloud';
const createServer = async (req) => {
const timer = logRequestTimer(req);
const reqUrl = new URL(req.url);
logger.info(`Request URL: ${reqUrl.pathname}`);
const [
namespace,
publishName,
] = reqUrl.pathname.slice(1).split('/');
logger.info(`Namespace: ${namespace}, Publish Name: ${publishName}`);
if (namespace === '' || !publishName) {
timer();
return new Response(null, {
status: 302,
headers: {
'Location': 'https://appflowy.io',
},
});
}
if (req.method === 'GET') {
const pageId = req.url.split('/').pop();
let metaData;
try {
metaData = await fetchMetaData(`${BASE_URL}/api/workspace/published/${namespace}/${publishName}`);
} catch (error) {
logger.error(`Error fetching meta data: ${error}`);
}
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;
// }
if (metaData) {
title = metaData.view.name;
try {
const cover = metaData.view.extra ? JSON.parse(metaData.view.extra)?.cover : null;
if (cover && ['unsplash', 'custom'].includes(cover.type)) {
image = cover.value;
}
} catch (_) {
// Do nothing
}
}
$('title').text(title);
setOrUpdateMetaTag($, 'meta[name="description"]', 'name', description);
@@ -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 = "6262816043efeede8823d7a7ea252083adf407e9" }
client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "fa7565d9de2a5e65e3886c8230588d58140637c5" }
[dependencies]
serde_json.workspace = true
@@ -18,7 +18,6 @@ import {
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';
@@ -31,11 +30,9 @@ 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>
<DatabaseContextProvider viewId={viewId} databaseDoc={doc} rowDocMap={rowDocMap} readOnly={true}>
{children}
</DatabaseContextProvider>
);
};
@@ -1,4 +1,5 @@
import { YDatabase, YDatabaseRow, YDoc, YjsDatabaseKey, YjsEditorKey } from '@/application/collab.type';
import { ViewMeta } from '@/application/db/tables/view_metas';
import { createContext, useContext } from 'react';
import * as Y from 'yjs';
@@ -9,6 +10,10 @@ export interface DatabaseContextState {
rowDocMap: Y.Map<YDoc>;
isDatabaseRowPage?: boolean;
navigateToRow?: (rowId: string) => void;
loadView?: (viewId: string) => Promise<YDoc>;
getViewRowsMap?: (viewId: string, rowIds: string[]) => Promise<{ rows: Y.Map<YDoc>; destroy: () => void }>;
loadViewMeta?: (viewId: string) => Promise<ViewMeta>;
navigateToView?: (viewId: string) => Promise<void>;
}
export const DatabaseContext = createContext<DatabaseContextState | null>(null);
@@ -1,12 +1,4 @@
import {
FieldId,
SortId,
YDatabaseField,
YDoc,
YjsDatabaseKey,
YjsEditorKey,
YjsFolderKey,
} from '@/application/collab.type';
import { FieldId, SortId, YDatabaseField, YDoc, YjsDatabaseKey, YjsEditorKey } from '@/application/collab.type';
import { getCell, metaIdFromRowId, MIN_COLUMN_WIDTH } from '@/application/database-yjs/const';
import {
useDatabase,
@@ -19,7 +11,6 @@ import {
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 '@/application/database-yjs/cell.parse';
import { DateTimeCell } from '@/application/database-yjs/cell.type';
import * as dayjs from 'dayjs';
@@ -42,9 +33,8 @@ export interface Row {
const defaultVisible = [FieldVisibility.AlwaysShown, FieldVisibility.HideWhenEmpty];
export function useDatabaseViewsSelector(iidIndex: string) {
export function useDatabaseViewsSelector(_iidIndex: string) {
const database = useDatabase();
const { viewsId: visibleViewsId, views: folderViews } = useViewsIdSelector();
const views = database?.get(YjsDatabaseKey.views);
const [viewIds, setViewIds] = useState<string[]>([]);
@@ -65,22 +55,7 @@ export function useDatabaseViewsSelector(iidIndex: string) {
return Number(viewB.created_at) - Number(viewA.created_at);
});
const viewsId = [];
for (const viewItem of viewsSorted) {
const [key] = viewItem;
const view = folderViews?.get(key);
if (
visibleViewsId.includes(key) &&
view &&
(view.get(YjsFolderKey.bid) === iidIndex || view.get(YjsFolderKey.id) === iidIndex)
) {
viewsId.push(key);
}
}
setViewIds(viewsId);
setViewIds(viewsSorted.map(([key]) => key));
};
observerEvent();
@@ -89,7 +64,7 @@ export function useDatabaseViewsSelector(iidIndex: string) {
return () => {
views.unobserve(observerEvent);
};
}, [visibleViewsId, views, folderViews, iidIndex]);
}, [views]);
return {
childViews,
@@ -2,6 +2,17 @@ import { YDoc } from '@/application/collab.type';
import { databasePrefix } from '@/application/constants';
import { IndexeddbPersistence } from 'y-indexeddb';
import * as Y from 'yjs';
import BaseDexie from 'dexie';
import { viewMetasSchema, ViewMetasTable } from '@/application/db/tables/view_metas';
type DexieTables = ViewMetasTable;
export type Dexie<T = DexieTables> = BaseDexie & T;
export const db = new BaseDexie(`${databasePrefix}_cache`) as Dexie;
const schema = Object.assign({}, viewMetasSchema);
db.version(1).stores(schema);
const openedSet = new Set<string>();
@@ -31,11 +42,3 @@ export async function openCollabDB(docName: string): Promise<YDoc> {
return doc as YDoc;
}
export function getCollabDBName(id: string, type: string, uuid?: string) {
if (!uuid) {
return `${type}_${id}`;
}
return `${uuid}_${type}_${id}`;
}
@@ -0,0 +1,28 @@
import { Table } from 'dexie';
export interface MetaData {
view_id: string;
name: string;
icon: string | null;
layout: number;
extra: string | null;
created_by: string | null;
last_edited_by: string | null;
last_edited_time: string;
created_at: string;
}
export type ViewMeta = {
publish_name: string;
child_views: MetaData[];
ancestor_views: MetaData[];
} & MetaData;
export type ViewMetasTable = {
view_metas: Table<ViewMeta>;
};
export const viewMetasSchema = {
view_metas: 'publish_name',
};
@@ -1,38 +0,0 @@
import { ViewLayout, YFolder, YjsFolderKey } from '@/application/collab.type';
import { createContext, useContext } from 'react';
import { useParams } from 'react-router-dom';
export interface Crumb {
viewId: string;
rowId?: string;
name: string;
icon: string;
}
export const FolderContext = createContext<{
folder: YFolder | null;
onNavigateToView?: (viewId: string) => void;
crumbs?: Crumb[];
setCrumbs?: React.Dispatch<React.SetStateAction<Crumb[]>>;
} | null>(null);
export const useFolderContext = () => {
return useContext(FolderContext)?.folder;
};
export const useViewLayout = () => {
const folder = useFolderContext();
const { objectId } = useParams();
const views = folder?.get(YjsFolderKey.views);
const view = objectId ? views?.get(objectId) : null;
return Number(view?.get(YjsFolderKey.layout)) as ViewLayout;
};
export const useNavigateToView = () => {
return useContext(FolderContext)?.onNavigateToView;
};
export const useCrumbs = () => {
return useContext(FolderContext)?.crumbs;
};
@@ -1,9 +0,0 @@
export enum CoverType {
NormalColor = 'color',
GradientColor = 'gradient',
BuildInImage = 'built_in',
CustomImage = 'custom',
LocalImage = 'local',
UpsplashImage = 'unsplash',
None = 'none',
}
@@ -1,2 +0,0 @@
export * from './selector';
export * from './context';
@@ -1,70 +0,0 @@
import { YjsFolderKey, YView } from '@/application/collab.type';
import { useFolderContext } from '@/application/folder-yjs/context';
import { useEffect, useState } from 'react';
export function useViewsIdSelector() {
const folder = useFolderContext();
const [viewsId, setViewsId] = useState<string[]>([]);
const views = folder?.get(YjsFolderKey.views);
const trash = folder?.get(YjsFolderKey.section)?.get(YjsFolderKey.trash);
const meta = folder?.get(YjsFolderKey.meta);
useEffect(() => {
if (!views) {
return;
}
const trashUid = trash ? Array.from(trash.keys())[0] : null;
const userTrash = trashUid ? trash?.get(trashUid) : null;
const collectIds = () => {
const trashIds = userTrash?.toJSON()?.map((item) => item.id) || [];
return Array.from(views.keys()).filter((id) => {
return !trashIds.includes(id) && id !== meta?.get(YjsFolderKey.current_workspace);
});
};
setViewsId(collectIds());
const observerEvent = () => setViewsId(collectIds());
views.observe(observerEvent);
userTrash?.observe(observerEvent);
return () => {
views.unobserve(observerEvent);
userTrash?.unobserve(observerEvent);
};
}, [views, trash, meta]);
return {
viewsId,
views,
};
}
export function useViewSelector(viewId: string) {
const folder = useFolderContext();
const [clock, setClock] = useState<number>(0);
const [view, setView] = useState<YView | null>(null);
useEffect(() => {
if (!folder) return;
const view = folder.get(YjsFolderKey.views)?.get(viewId);
setView(view || null);
const observerEvent = () => setClock((prev) => prev + 1);
view?.observe(observerEvent);
return () => {
view?.unobserve(observerEvent);
};
}, [folder, viewId]);
return {
clock,
view,
};
}
@@ -0,0 +1,151 @@
import { YDoc } from '@/application/collab.type';
import { db } from '@/application/db';
import { ViewMeta } from '@/application/db/tables/view_metas';
import { notify } from '@/components/_shared/notify';
import { AFConfigContext } from '@/components/app/AppConfig';
import { useLiveQuery } from 'dexie-react-hooks';
import { createContext, useCallback, useContext } from 'react';
import { useNavigate } from 'react-router-dom';
import * as Y from 'yjs';
export interface PublishContextType {
namespace: string;
publishName: string;
viewMeta?: ViewMeta;
toView: (viewId: string) => Promise<void>;
loadViewMeta: (viewId: string) => Promise<ViewMeta>;
getViewRowsMap?: (viewId: string, rowIds: string[]) => Promise<{ rows: Y.Map<YDoc>; destroy: () => void }>;
loadView: (viewId: string) => Promise<YDoc>;
}
export const PublishContext = createContext<PublishContextType | null>(null);
export const PublishProvider = ({
children,
namespace,
publishName,
}: {
children: React.ReactNode;
namespace: string;
publishName: string;
}) => {
const viewMeta = useLiveQuery(async () => {
const name = `${namespace}_${publishName}`;
return db.view_metas.get(name);
});
const service = useContext(AFConfigContext)?.service;
const navigate = useNavigate();
const toView = useCallback(
async (viewId: string) => {
try {
const res = await service?.getPublishInfo(viewId);
if (!res) {
throw new Error('Not found');
}
const { namespace, publishName } = res;
navigate(`/${namespace}/${publishName}`);
} catch (e) {
notify.error('The view has not been published yet.');
return Promise.reject(e);
}
},
[navigate, service]
);
const loadViewMeta = useCallback(
async (viewId: string) => {
try {
const info = await service?.getPublishInfo(viewId);
if (!info) {
throw new Error('View has not been published yet');
}
const res = await service?.getPublishViewMeta(namespace, publishName);
if (!res) {
throw new Error('View has not been published yet');
}
return res;
} catch (e) {
return Promise.reject(e);
}
},
[namespace, publishName, service]
);
const getViewRowsMap = useCallback(
async (viewId: string, rowIds: string[]) => {
try {
const info = await service?.getPublishInfo(viewId);
if (!info) {
throw new Error('View has not been published yet');
}
const { namespace, publishName } = info;
const res = await service?.getPublishDatabaseViewRows(namespace, publishName, rowIds);
if (!res) {
throw new Error('View has not been published yet');
}
return res;
} catch (e) {
return Promise.reject(e);
}
},
[service]
);
const loadView = useCallback(
async (viewId: string) => {
try {
const res = await service?.getPublishInfo(viewId);
if (!res) {
throw new Error('View has not been published yet');
}
const { namespace, publishName } = res;
const data = service?.getPublishView(namespace, publishName);
if (!data) {
throw new Error('View has not been published yet');
}
return data;
} catch (e) {
return Promise.reject(e);
}
},
[service]
);
return (
<PublishContext.Provider
value={{
loadView,
viewMeta,
getViewRowsMap,
loadViewMeta,
toView,
namespace,
publishName,
}}
>
{children}
</PublishContext.Provider>
);
};
export function usePublishContext() {
return useContext(PublishContext);
}
@@ -0,0 +1 @@
export * from './context';
@@ -1,310 +0,0 @@
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('');
});
});
@@ -1,13 +1,13 @@
import { expect } from '@jest/globals';
import { fetchCollab, batchFetchCollab } from '../fetch';
import { CollabType } from '@/application/collab.type';
import { fetchPublishView, fetchPublishViewMeta, fetchViewInfo } from '../fetch';
import { APIService } from '@/application/services/js-services/wasm';
jest.mock('@/application/services/js-services/wasm', () => {
return {
APIService: {
getCollab: jest.fn(),
batchGetCollab: jest.fn(),
getPublishView: jest.fn(),
getPublishViewMeta: jest.fn(),
getPublishInfoWithViewId: jest.fn(),
},
};
});
@@ -17,41 +17,100 @@ describe('Collab fetch functions with deduplication', () => {
jest.clearAllMocks();
});
describe('fetchCollab', () => {
it('should fetch collab without duplicating requests', async () => {
const workspaceId = 'workspace1';
const id = 'id1';
const type = CollabType.Document;
describe('fetchPublishView', () => {
it('should fetch publish view without duplicating requests', async () => {
const namespace = 'namespace1';
const publishName = 'publish1';
const mockResponse = { data: 'mockData' };
(APIService.getCollab as jest.Mock).mockResolvedValue(mockResponse);
(APIService.getPublishView as jest.Mock).mockResolvedValue(mockResponse);
const result1 = fetchCollab(workspaceId, id, type);
const result2 = fetchCollab(workspaceId, id, type);
const result1 = fetchPublishView(namespace, publishName);
const result2 = fetchPublishView(namespace, publishName);
expect(result1).toBe(result2);
await expect(result1).resolves.toEqual(mockResponse);
expect(APIService.getCollab).toHaveBeenCalledTimes(1);
expect(APIService.getPublishView).toHaveBeenCalledTimes(1);
});
it('should fetch publish view with different params', async () => {
const namespace = 'namespace1';
const publishName = 'publish1';
const mockResponse = { data: 'mockData' };
(APIService.getPublishView as jest.Mock).mockResolvedValue(mockResponse);
const result1 = fetchPublishView(namespace, publishName);
const result2 = fetchPublishView(namespace, 'publish2');
expect(result1).not.toBe(result2);
await expect(result1).resolves.toEqual(mockResponse);
await expect(result2).resolves.toEqual(mockResponse);
expect(APIService.getPublishView).toHaveBeenCalledTimes(2);
});
});
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 },
];
describe('fetchViewInfo', () => {
it('should fetch view info without duplicating requests', async () => {
const viewId = 'view1';
const mockResponse = { data: 'mockData' };
(APIService.batchGetCollab as jest.Mock).mockResolvedValue(mockResponse);
(APIService.getPublishInfoWithViewId as jest.Mock).mockResolvedValue(mockResponse);
const result1 = batchFetchCollab(workspaceId, params);
const result2 = batchFetchCollab(workspaceId, params);
const result1 = fetchViewInfo(viewId);
const result2 = fetchViewInfo(viewId);
expect(result1).toBe(result2);
await expect(result1).resolves.toEqual(mockResponse);
expect(APIService.batchGetCollab).toHaveBeenCalledTimes(1);
expect(APIService.getPublishInfoWithViewId).toHaveBeenCalledTimes(1);
});
it('should fetch view info with different params', async () => {
const viewId = 'view1';
const mockResponse = { data: 'mockData' };
(APIService.getPublishInfoWithViewId as jest.Mock).mockResolvedValue(mockResponse);
const result1 = fetchViewInfo(viewId);
const result2 = fetchViewInfo('view2');
expect(result1).not.toBe(result2);
await expect(result1).resolves.toEqual(mockResponse);
await expect(result2).resolves.toEqual(mockResponse);
expect(APIService.getPublishInfoWithViewId).toHaveBeenCalledTimes(2);
});
});
describe('fetchPublishViewMeta', () => {
it('should fetch publish view meta without duplicating requests', async () => {
const namespace = 'namespace1';
const publishName = 'publish1';
const mockResponse = { data: 'mockData' };
(APIService.getPublishViewMeta as jest.Mock).mockResolvedValue(mockResponse);
const result1 = fetchPublishViewMeta(namespace, publishName);
const result2 = fetchPublishViewMeta(namespace, publishName);
expect(result1).toBe(result2);
await expect(result1).resolves.toEqual(mockResponse);
expect(APIService.getPublishViewMeta).toHaveBeenCalledTimes(1);
});
it('should fetch publish view meta with different params', async () => {
const namespace = 'namespace1';
const publishName = 'publish1';
const mockResponse = { data: 'mockData' };
(APIService.getPublishViewMeta as jest.Mock).mockResolvedValue(mockResponse);
const result1 = fetchPublishViewMeta(namespace, publishName);
const result2 = fetchPublishViewMeta(namespace, 'publish2');
expect(result1).not.toBe(result2);
await expect(result1).resolves.toEqual(mockResponse);
await expect(result2).resolves.toEqual(mockResponse);
expect(APIService.getPublishViewMeta).toHaveBeenCalledTimes(2);
});
});
});
@@ -0,0 +1,128 @@
import { withTestingYDoc } from '@/application/slate-yjs/__tests__/withTestingYjsEditor';
import { AFClientService } from '../index';
import { fetchViewInfo } from '@/application/services/js-services/fetch';
import { expect, jest } from '@jest/globals';
import { getBatchCollabs, getPublishView, getPublishViewMeta } from '@/application/services/js-services/cache';
jest.mock('@/application/services/js-services/wasm/client_api', () => {
return {
initAPIService: jest.fn(),
};
});
jest.mock('nanoid', () => {
return {
nanoid: jest.fn().mockReturnValue('12345678'),
};
});
jest.mock('@/application/services/js-services/fetch', () => {
return {
fetchPublishView: jest.fn(),
fetchPublishViewMeta: jest.fn(),
fetchViewInfo: jest.fn(),
};
});
jest.mock('@/application/services/js-services/cache', () => {
return {
getPublishView: jest.fn(),
getPublishViewMeta: jest.fn(),
getBatchCollabs: jest.fn(),
};
});
describe('AFClientService', () => {
let service: AFClientService;
beforeEach(() => {
jest.clearAllMocks();
service = new AFClientService({
cloudConfig: {
baseURL: 'http://localhost:3000',
gotrueURL: 'http://localhost:3000',
wsURL: 'ws://localhost:3000',
},
});
});
it('should get view meta', async () => {
const namespace = 'namespace';
const publishName = 'publishName';
const mockResponse = {
view_id: 'view_id',
publish_name: publishName,
metadata: {
view: {
name: 'viewName',
view_id: 'view_id',
},
child_views: [],
ancestor_views: [],
},
};
// @ts-ignore
(getPublishViewMeta as jest.Mock).mockResolvedValue(mockResponse);
const result = await service.getPublishViewMeta(namespace, publishName);
expect(result).toEqual(mockResponse);
});
it('should get view', async () => {
const namespace = 'namespace';
const publishName = 'publishName';
const mockResponse = {
data: [1, 2, 3],
meta: {
metadata: {
view: {
name: 'viewName',
view_id: 'view_id',
},
child_views: [],
ancestor_views: [],
},
},
};
// @ts-ignore
(getPublishView as jest.Mock).mockResolvedValue(mockResponse);
const result = await service.getPublishView(namespace, publishName);
expect(result).toEqual(mockResponse);
});
it('should get view info', async () => {
const viewId = 'viewId';
const mockResponse = {
namespace: 'namespace',
publish_name: 'publishName',
};
// @ts-ignore
(fetchViewInfo as jest.Mock).mockResolvedValue(mockResponse);
const result = await service.getPublishInfo(viewId);
expect(result).toEqual({
namespace: 'namespace',
publishName: 'publishName',
});
});
it('getPublishDatabaseViewRows', async () => {
const namespace = 'namespace';
const publishName = 'publishName';
const rowIds = ['1', '2', '3'];
const mockResponse = [withTestingYDoc('1'), withTestingYDoc('2'), withTestingYDoc('3')];
// @ts-ignore
(getBatchCollabs as jest.Mock).mockResolvedValue(mockResponse);
const result = await service.getPublishDatabaseViewRows(namespace, publishName, rowIds);
expect(result).toEqual({
rows: expect.any(Object),
destroy: expect.any(Function),
});
});
});
@@ -1,39 +0,0 @@
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/session/auth';
import { invalidToken } from 'src/application/services/js-services/session';
import { afterSignInDecorator } from '@/application/services/js-services/decorator';
export class JSAuthService implements AuthService {
constructor() {
// Do nothing
}
getOAuthURL = async (_provider: ProviderType): Promise<string> => {
return Promise.reject('Not implemented');
};
@afterSignInDecorator(signInSuccess)
async signInWithOAuth(_: { uri: string }): Promise<void> {
return Promise.reject('Not implemented');
}
signupWithEmailPassword = async (_params: SignUpWithEmailPasswordParams): Promise<void> => {
return Promise.reject('Not implemented');
};
@afterSignInDecorator(signInSuccess)
async signinWithEmailPassword(email: string, password: string): Promise<void> {
try {
return APIService.signIn(email, password);
} catch (e) {
return Promise.reject(e);
}
}
signOut = async (): Promise<void> => {
invalidToken();
return APIService.logout();
};
}
@@ -0,0 +1,145 @@
import { CollabType } from '@/application/collab.type';
import { withTestingYDoc } from '@/application/slate-yjs/__tests__/withTestingYjsEditor';
import { expect } from '@jest/globals';
import {
collabTypeToDBType,
getPublishView,
getPublishViewMeta,
getBatchCollabs,
} from '@/application/services/js-services/cache';
import { openCollabDB, db } from '@/application/db';
import { StrategyType } from '@/application/services/js-services/cache/types';
jest.mock('@/application/ydoc/apply', () => ({
applyYDoc: jest.fn(),
}));
jest.mock('@/application/db', () => ({
openCollabDB: jest.fn(),
db: {
view_metas: {
get: jest.fn(),
put: jest.fn(),
},
},
}));
const normalDoc = withTestingYDoc('1');
const mockFetcher = jest.fn();
async function runTestWithStrategy(strategy: StrategyType) {
return getPublishView(
mockFetcher,
{
namespace: 'appflowy',
publishName: 'test',
},
strategy
);
}
async function runGetPublishViewMetaWithStrategy(strategy: StrategyType) {
return getPublishViewMeta(
mockFetcher,
{
namespace: 'appflowy',
publishName: 'test',
},
strategy
);
}
describe('Cache functions', () => {
beforeEach(() => {
jest.clearAllMocks();
mockFetcher.mockClear();
(openCollabDB as jest.Mock).mockClear();
});
describe('getPublishView', () => {
it('should call fetcher when no cache found', async () => {
mockFetcher.mockResolvedValue({ data: [1, 2, 3], meta: { metadata: { view: { id: '1' } } } });
(db.view_metas.get as jest.Mock).mockResolvedValue(undefined);
await runTestWithStrategy(StrategyType.CACHE_FIRST);
expect(mockFetcher).toBeCalledTimes(1);
await runTestWithStrategy(StrategyType.CACHE_AND_NETWORK);
expect(mockFetcher).toBeCalledTimes(2);
await expect(runTestWithStrategy(StrategyType.CACHE_ONLY)).rejects.toThrow('No cache found');
});
it('should call fetcher when cache is invalid or strategy is CACHE_AND_NETWORK', async () => {
(openCollabDB as jest.Mock).mockResolvedValue(normalDoc);
(db.view_metas.get as jest.Mock).mockResolvedValue({ view_id: '1' });
mockFetcher.mockResolvedValue({ data: [1, 2, 3], meta: { metadata: { view: { id: '1' } } } });
await runTestWithStrategy(StrategyType.CACHE_ONLY);
expect(openCollabDB).toBeCalledTimes(1);
await runTestWithStrategy(StrategyType.CACHE_FIRST);
expect(openCollabDB).toBeCalledTimes(2);
expect(mockFetcher).toBeCalledTimes(0);
await runTestWithStrategy(StrategyType.CACHE_AND_NETWORK);
expect(openCollabDB).toBeCalledTimes(3);
expect(mockFetcher).toBeCalledTimes(1);
});
});
describe('getPublishViewMeta', () => {
it('should call fetcher when no cache found', async () => {
mockFetcher.mockResolvedValue({ metadata: { view: { id: '1' }, child_views: [], ancestor_views: [] } });
(db.view_metas.get as jest.Mock).mockResolvedValue(undefined);
await runGetPublishViewMetaWithStrategy(StrategyType.CACHE_FIRST);
expect(mockFetcher).toBeCalledTimes(1);
await runGetPublishViewMetaWithStrategy(StrategyType.CACHE_AND_NETWORK);
expect(mockFetcher).toBeCalledTimes(2);
await expect(runGetPublishViewMetaWithStrategy(StrategyType.CACHE_ONLY)).rejects.toThrow('No cache found');
});
it('should call fetcher when cache is invalid or strategy is CACHE_AND_NETWORK', async () => {
(openCollabDB as jest.Mock).mockResolvedValue(normalDoc);
(db.view_metas.get as jest.Mock).mockResolvedValue({ view_id: '1' });
mockFetcher.mockResolvedValue({ metadata: { view: { id: '1' }, child_views: [], ancestor_views: [] } });
const meta = await runGetPublishViewMetaWithStrategy(StrategyType.CACHE_ONLY);
expect(openCollabDB).toBeCalledTimes(0);
expect(meta).toBeDefined();
await runGetPublishViewMetaWithStrategy(StrategyType.CACHE_FIRST);
expect(openCollabDB).toBeCalledTimes(0);
expect(mockFetcher).toBeCalledTimes(0);
await runGetPublishViewMetaWithStrategy(StrategyType.CACHE_AND_NETWORK);
expect(openCollabDB).toBeCalledTimes(0);
expect(mockFetcher).toBeCalledTimes(1);
});
});
describe('getBatchCollabs', () => {
it('should return empty array when no cache found', async () => {
(openCollabDB as jest.Mock).mockResolvedValue(undefined);
const collabs = await getBatchCollabs(['1', '2', '3']);
expect(collabs).toEqual([]);
});
it('should return collabs when cache found', async () => {
(openCollabDB as jest.Mock).mockResolvedValue(normalDoc);
(db.view_metas.get as jest.Mock).mockResolvedValue({ view_id: '1' });
const collabs = await getBatchCollabs(['1', '2', '3']);
expect(collabs).toEqual([normalDoc, normalDoc, normalDoc]);
});
});
});
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('');
});
});
@@ -1,7 +1,8 @@
import { MetaData } from '@/application/db/tables/view_metas';
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';
import { db, openCollabDB } from '@/application/db';
import { Fetcher, StrategyType } from '@/application/services/js-services/cache/types';
export function collabTypeToDBType(type: CollabType) {
switch (type) {
@@ -32,30 +33,42 @@ const collabSharedRootKeyMap = {
[CollabType.Empty]: YjsEditorKey.empty,
};
export function hasCache(doc: YDoc, type: CollabType) {
export function hasCollabCache(doc: YDoc) {
const data = doc.getMap(YjsEditorKey.data_section) as YSharedRoot;
return data.has(collabSharedRootKeyMap[type] as string);
return Object.values(collabSharedRootKeyMap).some((key) => {
return data.has(key);
});
}
export async function getCollab(
fetcher: Fetcher<{
state: Uint8Array;
}>,
export async function hasViewMetaCache(name: string) {
const data = await db.view_metas.get(name);
return !!data;
}
export async function getPublishViewMeta<
T extends {
metadata: {
view: MetaData;
child_views: MetaData[];
ancestor_views: MetaData[];
};
}
>(
fetcher: Fetcher<T>,
{
collabId,
collabType,
uuid,
namespace,
publishName,
}: {
uuid?: string;
collabId: string;
collabType: CollabType;
namespace: string;
publishName: string;
},
strategy: StrategyType = StrategyType.CACHE_AND_NETWORK
) {
const name = getCollabDBName(collabId, collabTypeToDBType(collabType), uuid);
const collab = await openCollabDB(name);
const exist = hasCache(collab, collabType);
const name = `${namespace}_${publishName}`;
const exist = await hasViewMetaCache(name);
const meta = await db.view_metas.get(name);
switch (strategy) {
case StrategyType.CACHE_ONLY: {
@@ -63,103 +76,155 @@ export async function getCollab(
throw new Error('No cache found');
}
return collab;
return meta;
}
case StrategyType.CACHE_FIRST: {
if (!exist) {
await revalidateCollab(fetcher, collab);
return revalidatePublishViewMeta(name, fetcher);
}
return collab;
return meta;
}
case StrategyType.CACHE_AND_NETWORK: {
if (!exist) {
await revalidateCollab(fetcher, collab);
return revalidatePublishViewMeta(name, fetcher);
} else {
void revalidateCollab(fetcher, collab);
void revalidatePublishViewMeta(name, fetcher);
}
return collab;
return meta;
}
default: {
await revalidateCollab(fetcher, collab);
return collab;
return revalidatePublishViewMeta(name, fetcher);
}
}
}
async function revalidateCollab(
fetcher: Fetcher<{
state: Uint8Array;
}>,
collab: YDoc
export async function getPublishView<
T extends {
data: number[];
meta: {
metadata: {
view: MetaData;
child_views: MetaData[];
ancestor_views: MetaData[];
};
};
}
>(
fetcher: Fetcher<T>,
{
namespace,
publishName,
}: {
namespace: string;
publishName: string;
},
strategy: StrategyType = StrategyType.CACHE_AND_NETWORK
) {
const { state } = await fetcher();
const name = `${namespace}_${publishName}`;
const doc = await openCollabDB(name);
const exist = (await hasViewMetaCache(name)) && hasCollabCache(doc);
switch (strategy) {
case StrategyType.CACHE_ONLY: {
if (!exist) {
throw new Error('No cache found');
}
break;
}
case StrategyType.CACHE_FIRST: {
if (!exist) {
await revalidatePublishView(name, fetcher, doc);
}
break;
}
case StrategyType.CACHE_AND_NETWORK: {
if (!exist) {
await revalidatePublishView(name, fetcher, doc);
} else {
void revalidatePublishView(name, fetcher, doc);
}
break;
}
default: {
await revalidatePublishView(name, fetcher, doc);
break;
}
}
return doc;
}
export async function revalidatePublishViewMeta<
T extends {
metadata: {
view: MetaData;
child_views: MetaData[];
ancestor_views: MetaData[];
};
}
>(name: string, fetcher: Fetcher<T>) {
const { metadata } = await fetcher();
await db.view_metas.put(
{
publish_name: name,
...metadata.view,
child_views: metadata.child_views,
ancestor_views: metadata.ancestor_views,
},
name
);
}
export async function revalidatePublishView<
T extends {
data: number[];
rows?: Record<string, number[]>;
meta: {
metadata: {
view: MetaData;
child_views: MetaData[];
ancestor_views: MetaData[];
};
};
}
>(name: string, fetcher: Fetcher<T>, collab: YDoc) {
const { data, meta, rows } = await fetcher();
await db.view_metas.put(
{
publish_name: name,
...meta.metadata.view,
child_views: meta.metadata.child_views,
ancestor_views: meta.metadata.ancestor_views,
},
name
);
for (const [key, value] of Object.entries(rows ?? {})) {
const row = await openCollabDB(`${name}_${key}`);
applyYDoc(row, new Uint8Array(value));
}
const state = new Uint8Array(data);
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>();
export async function getBatchCollabs(names: string[]) {
const collabs = await Promise.all(names.map((name) => openCollabDB(name)));
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);
}
return collabs;
}
@@ -1,157 +0,0 @@
import { CollabType, YDatabase, YDoc, YjsDatabaseKey, YjsEditorKey } from '@/application/collab.type';
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() {
//
}
currentWorkspace() {
return getCurrentWorkspace();
}
async getWorkspaceDatabases(): Promise<{ views: string[]; database_id: string }[]> {
const workspace = await this.currentWorkspace();
if (!workspace) {
throw new Error('Workspace database not found');
}
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): Promise<{
databaseDoc: YDoc;
rows: Y.Map<YDoc>;
}> {
const workspace = await this.currentWorkspace();
if (!workspace) {
throw new Error('Workspace database not found');
}
const workspaceId = workspace.id;
const isLoaded = this.loadedDatabaseId.has(databaseId);
const rootRowsDoc =
this.cacheDatabaseRowDocMap.get(databaseId) ??
new Y.Doc({
guid: databaseId,
});
if (!this.cacheDatabaseRowDocMap.has(databaseId)) {
this.cacheDatabaseRowDocMap.set(databaseId, rootRowsDoc);
}
const rowsFolder: Y.Map<YDoc> = rootRowsDoc.getMap();
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) 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();
const rowOrders = database.get(YjsDatabaseKey.views)?.get(viewId)?.get(YjsDatabaseKey.row_orders);
const rowOrdersIds = rowOrders.toJSON() as {
id: string;
}[];
if (!rowOrdersIds) {
throw new Error('Database rows not found');
}
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);
}
}
);
// 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;
}[];
const params = rowIds.map((item) => ({
collabId: item.id,
collabType: CollabType.DatabaseRow,
}));
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,
rows: rowsFolder,
};
}
async closeDatabase(databaseId: string) {
this.cacheDatabaseRowDocMap.delete(databaseId);
}
}
@@ -1,60 +0,0 @@
/**
* @description:
* * This is a decorator that can be used to read data from storage and fetch data from the server.
* * If the data is already in storage, it will return the data from storage and fetch the data from the server in the background.
*
* @param getStorage A function that returns the data from storage. eg. `() => Promise<T | undefined>`
*
* @param setStorage A function that saves the data to storage. eg. `(data: T) => Promise<void>`
*
* @param fetchFunction A function that fetches the data from the server. eg. `(params: P) => Promise<T | undefined>`
*
* @returns: A function that returns the data from storage and fetches the data from the server in the background.
*/
export function asyncDataDecorator<P, T>(
getStorage: () => Promise<T | undefined>,
setStorage: (data: T) => Promise<void>,
fetchFunction: (params: P) => Promise<T | undefined>
) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
async function fetchData(params: P) {
const data = await fetchFunction(params);
if (!data) return;
await setStorage(data);
return data;
}
const originalMethod = descriptor.value;
descriptor.value = async function (params: P) {
const data = await getStorage();
await originalMethod.apply(this, [params]);
if (data) {
void fetchData(params);
return data;
} else {
return fetchData(params);
}
};
return descriptor;
};
}
export function afterSignInDecorator(successCallback: () => Promise<void>) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
descriptor.value = async function (...args: any[]) {
await originalMethod.apply(this, args);
await successCallback();
};
return descriptor;
};
}
@@ -1,47 +0,0 @@
import { CollabOrigin, CollabType, YDoc } from '@/application/collab.type';
import { getCollab } from '@/application/services/js-services/cache';
import { StrategyType } from '@/application/services/js-services/cache/types';
import { fetchCollab } from '@/application/services/js-services/fetch';
import { getCurrentWorkspace } from 'src/application/services/js-services/session';
import { DocumentService } from '@/application/services/services.type';
export class JSDocumentService implements DocumentService {
private loaded: Set<string> = new Set();
constructor() {
//
}
async openDocument(docId: string): Promise<YDoc> {
const workspace = await getCurrentWorkspace();
if (!workspace) {
throw new Error('Workspace database not found');
}
const isLoaded = this.loaded.has(docId);
const doc = await getCollab(
() => {
return fetchCollab(workspace.id, docId, CollabType.Document);
},
{
collabId: docId,
collabType: CollabType.Document,
},
isLoaded ? StrategyType.CACHE_FIRST : StrategyType.CACHE_AND_NETWORK
);
if (!isLoaded) this.loaded.add(docId);
const handleUpdate = (update: Uint8Array, origin: CollabOrigin) => {
if (origin === CollabOrigin.LocalSync) {
// Send the update to the server
console.log('update', update);
}
};
doc.on('update', handleUpdate);
return doc;
}
}
@@ -1,4 +1,3 @@
import { CollabType } from '@/application/collab.type';
import { APIService } from '@/application/services/js-services/wasm';
const pendingRequests = new Map();
@@ -31,36 +30,20 @@ function fetchWithDeduplication<Req, Res>(url: string, params: Req, fetchFunctio
return fetchPromise;
}
/**
* Fetch collab
* @param workspaceId
* @param id
* @param type [CollabType]
*/
export function fetchCollab(workspaceId: string, id: string, type: CollabType) {
const fetchFunction = () => APIService.getCollab(workspaceId, id, type);
export function fetchPublishView(namespace: string, publishName: string) {
const fetchFunction = () => APIService.getPublishView(namespace, publishName);
return fetchWithDeduplication(`fetchCollab_${workspaceId}`, { id, type }, fetchFunction);
return fetchWithDeduplication(`fetchPublishView_${namespace}`, { publishName }, fetchFunction);
}
/**
* Batch fetch collab
* Usage:
* // load database rows
* const rows = await batchFetchCollab(workspaceId, databaseRows.map((row) => ({ collabId: row.id, collabType: CollabType.DatabaseRow })));
*
* @param workspaceId
* @param params [{ collabId: string; collabType: CollabType }]
*/
export function batchFetchCollab(workspaceId: string, params: { collabId: string; collabType: CollabType }[]) {
const fetchFunction = () =>
APIService.batchGetCollab(
workspaceId,
params.map(({ collabId, collabType }) => ({
object_id: collabId,
collab_type: collabType,
}))
);
export function fetchViewInfo(viewId: string) {
const fetchFunction = () => APIService.getPublishInfoWithViewId(viewId);
return fetchWithDeduplication(`batchFetchCollab_${workspaceId}`, params, fetchFunction);
return fetchWithDeduplication(`fetchViewInfo`, { viewId }, fetchFunction);
}
export function fetchPublishViewMeta(namespace: string, publishName: string) {
const fetchFunction = () => APIService.getPublishViewMeta(namespace, publishName);
return fetchWithDeduplication(`fetchPublishViewMeta_${namespace}`, { publishName }, fetchFunction);
}
@@ -1,39 +0,0 @@
import { CollabOrigin, CollabType, YDoc } from '@/application/collab.type';
import { getCollab } from '@/application/services/js-services/cache';
import { StrategyType } from '@/application/services/js-services/cache/types';
import { fetchCollab } from '@/application/services/js-services/fetch';
import { FolderService } from '@/application/services/services.type';
export class JSFolderService implements FolderService {
private loaded: Set<string> = new Set();
constructor() {
//
}
async openWorkspace(workspaceId: string): Promise<YDoc> {
const isLoaded = this.loaded.has(workspaceId);
const doc = await getCollab(
() => {
return fetchCollab(workspaceId, workspaceId, CollabType.Folder);
},
{
collabId: workspaceId,
collabType: CollabType.Folder,
},
isLoaded ? StrategyType.CACHE_FIRST : StrategyType.CACHE_AND_NETWORK
);
if (!isLoaded) this.loaded.add(workspaceId);
const handleUpdate = (update: Uint8Array, origin: CollabOrigin) => {
if (origin === CollabOrigin.LocalSync) {
// Send the update to the server
console.log('update', update);
}
};
doc.on('update', handleUpdate);
return doc;
}
}
@@ -1,42 +1,28 @@
import { JSDatabaseService } from '@/application/services/js-services/database.service';
import {
AFService,
AFServiceConfig,
AuthService,
DatabaseService,
DocumentService,
FolderService,
UserService,
} from '@/application/services/services.type';
import { JSUserService } from '@/application/services/js-services/user.service';
import { JSAuthService } from '@/application/services/js-services/auth.service';
import { JSFolderService } from '@/application/services/js-services/folder.service';
import { JSDocumentService } from '@/application/services/js-services/document.service';
import { YDoc } from '@/application/collab.type';
import { getBatchCollabs, getPublishView, getPublishViewMeta } from '@/application/services/js-services/cache';
import { StrategyType } from '@/application/services/js-services/cache/types';
import { fetchPublishView, fetchPublishViewMeta, fetchViewInfo } from '@/application/services/js-services/fetch';
import { AFService, AFServiceConfig } from '@/application/services/services.type';
import { nanoid } from 'nanoid';
import { initAPIService } from '@/application/services/js-services/wasm/client_api';
import * as Y from 'yjs';
export class AFClientService implements AFService {
authService: AuthService;
userService: UserService;
documentService: DocumentService;
folderService: FolderService;
databaseService: DatabaseService;
private deviceId: string = nanoid(8);
private clientId: string = 'web';
getDeviceID = (): string => {
return this.deviceId;
};
private publishViewLoaded: Set<string> = new Set();
getClientID = (): string => {
return this.clientId;
};
private publishViewInfo: Map<
string,
{
namespace: string;
publishName: string;
}
> = new Map();
private cacheDatabaseRowDocMap: Map<string, Y.Doc> = new Map();
constructor(config: AFServiceConfig) {
initAPIService({
@@ -44,11 +30,104 @@ export class AFClientService implements AFService {
deviceId: this.deviceId,
clientId: this.clientId,
});
}
this.authService = new JSAuthService();
this.userService = new JSUserService();
this.documentService = new JSDocumentService();
this.folderService = new JSFolderService();
this.databaseService = new JSDatabaseService();
async getPublishViewMeta(namespace: string, publishName: string) {
const viewMeta = await getPublishViewMeta(
() => {
return fetchPublishViewMeta(namespace, publishName);
},
{
namespace,
publishName,
},
StrategyType.CACHE_AND_NETWORK
);
if (!viewMeta) {
return Promise.reject(new Error('View has not been published yet'));
}
return viewMeta;
}
async getPublishView(namespace: string, publishName: string) {
const name = `${namespace}_${publishName}`;
const isLoaded = this.publishViewLoaded.has(name);
const doc = await getPublishView(
() => {
return fetchPublishView(namespace, publishName);
},
{
namespace,
publishName,
},
isLoaded ? StrategyType.CACHE_FIRST : StrategyType.CACHE_AND_NETWORK
);
if (!isLoaded) {
this.publishViewLoaded.add(name);
}
return doc;
}
async getPublishDatabaseViewRows(namespace: string, publishName: string, rowIds: string[]) {
const name = `${namespace}_${publishName}`;
if (!this.publishViewLoaded.has(name)) {
await this.getPublishView(namespace, publishName);
}
const rootRowsDoc =
this.cacheDatabaseRowDocMap.get(name) ??
new Y.Doc({
guid: name,
});
if (!this.cacheDatabaseRowDocMap.has(name)) {
this.cacheDatabaseRowDocMap.set(name, rootRowsDoc);
}
const rowsFolder: Y.Map<YDoc> = rootRowsDoc.getMap();
const docs = await getBatchCollabs(rowIds);
docs.forEach((doc, index) => {
rowsFolder.set(rowIds[index], doc);
});
return {
rows: rowsFolder,
destroy: () => {
this.cacheDatabaseRowDocMap.delete(name);
rootRowsDoc.destroy();
},
};
}
async getPublishInfo(viewId: string) {
if (this.publishViewInfo.has(viewId)) {
return this.publishViewInfo.get(viewId) as {
namespace: string;
publishName: string;
};
}
const info = await fetchViewInfo(viewId);
const namespace = info.namespace;
if (!namespace) {
return Promise.reject(new Error('View not found'));
}
const data = {
namespace,
publishName: info.publish_name,
};
this.publishViewInfo.set(viewId, data);
return data;
}
}
@@ -1,3 +0,0 @@
export async function signInSuccess() {
// Do nothing
}
@@ -1,3 +0,0 @@
export * from './token';
export * from './user';
export * from './auth';
@@ -1,37 +0,0 @@
import { notify } from '@/components/_shared/notify';
const tokenKey = 'token';
export function readTokenStr() {
return sessionStorage.getItem(tokenKey);
}
export function getAuthInfo() {
const token = readTokenStr() || '';
try {
const info = JSON.parse(token);
return {
uuid: info.user.id,
access_token: info.access_token,
email: info.user.email,
};
} catch (e) {
return;
}
}
export function writeToken(token: string) {
if (!token) {
invalidToken();
return;
}
sessionStorage.setItem(tokenKey, token);
}
export function invalidToken() {
sessionStorage.removeItem(tokenKey);
notify.error('Invalid token, please login again');
}
@@ -1,43 +0,0 @@
import { UserProfile, UserWorkspace, Workspace } from '@/application/user.type';
const userKey = 'user';
const workspaceKey = 'workspace';
export async function getSignInUser(): Promise<UserProfile | undefined> {
const userStr = localStorage.getItem(userKey);
try {
return userStr ? JSON.parse(userStr) : undefined;
} catch (e) {
return undefined;
}
}
export async function setSignInUser(profile: UserProfile) {
const userStr = JSON.stringify(profile);
localStorage.setItem(userKey, userStr);
}
export async function getUserWorkspace(): Promise<UserWorkspace | undefined> {
const str = localStorage.getItem(workspaceKey);
try {
return str ? JSON.parse(str) : undefined;
} catch (e) {
return undefined;
}
}
export async function setUserWorkspace(workspace: UserWorkspace) {
const str = JSON.stringify(workspace);
localStorage.setItem(workspaceKey, str);
}
export async function getCurrentWorkspace(): Promise<Workspace | undefined> {
const userProfile = await getSignInUser();
const userWorkspace = await getUserWorkspace();
return userWorkspace?.workspaces.find((workspace) => workspace.id === userProfile?.workspaceId);
}
@@ -1,45 +0,0 @@
import { UserService } from '@/application/services/services.type';
import { UserProfile, UserWorkspace } from '@/application/user.type';
import { APIService } from 'src/application/services/js-services/wasm';
import {
getAuthInfo,
getSignInUser,
getUserWorkspace,
invalidToken,
setSignInUser,
setUserWorkspace,
} from 'src/application/services/js-services/session';
import { asyncDataDecorator } from '@/application/services/js-services/decorator';
async function getUser() {
try {
const user = await APIService.getUser();
return user;
} catch (e) {
console.error(e);
invalidToken();
}
}
export class JSUserService implements UserService {
@asyncDataDecorator<void, UserProfile>(getSignInUser, setSignInUser, getUser)
async getUserProfile(): Promise<UserProfile> {
if (!getAuthInfo()) {
return Promise.reject('Not authenticated');
}
await this.getUserWorkspace();
return null!;
}
async checkUser(): Promise<boolean> {
return (await getSignInUser()) !== undefined;
}
@asyncDataDecorator<void, UserWorkspace>(getUserWorkspace, setUserWorkspace, APIService.getUserWorkspace)
async getUserWorkspace(): Promise<UserWorkspace> {
return null!;
}
}
@@ -1,8 +1,5 @@
import { CollabType } from '@/application/collab.type';
import { ClientAPI } from '@appflowyinc/client-api-wasm';
import { UserProfile, UserWorkspace } from '@/application/user.type';
import { AFCloudConfig } from '@/application/services/services.type';
import { invalidToken, readTokenStr, writeToken } from 'src/application/services/js-services/session';
let client: ClientAPI;
@@ -12,8 +9,14 @@ export function initAPIService(
clientId: string;
}
) {
window.refresh_token = writeToken;
window.invalid_token = invalidToken;
window.refresh_token = () => {
//
};
window.invalid_token = () => {
// invalidToken();
};
client = ClientAPI.new({
base_url: config.baseURL,
ws_addr: config.wsURL,
@@ -26,96 +29,17 @@ export function initAPIService(
},
});
const token = readTokenStr();
if (token) {
client.restore_token(token);
}
client.subscribe();
}
export function signIn(email: string, password: string) {
return client.login(email, password);
export async function getPublishView(publishNamespace: string, publishName: string) {
return client.get_publish_view(publishNamespace, publishName);
}
export function logout() {
return client.logout();
export async function getPublishInfoWithViewId(viewId: string) {
return client.get_publish_info(viewId);
}
export async function getUser(): Promise<UserProfile> {
try {
const user = await client.get_user();
if (!user) {
throw new Error('No user found');
}
return {
uid: parseInt(user.uid),
uuid: user.uuid || undefined,
email: user.email || undefined,
name: user.name || undefined,
workspaceId: user.latest_workspace_id,
iconUrl: user.icon_url || undefined,
};
} catch (e) {
return Promise.reject(e);
}
}
export async function getCollab(workspaceId: string, object_id: string, collabType: CollabType) {
const res = await client.get_collab({
workspace_id: workspaceId,
object_id: object_id,
collab_type: Number(collabType) as 0 | 1 | 2 | 3 | 4 | 5,
});
const state = new Uint8Array(res.doc_state);
return {
state,
};
}
export async function batchGetCollab(
workspaceId: string,
params: {
object_id: string;
collab_type: CollabType;
}[]
) {
const res = (await client.batch_get_collab(
workspaceId,
params.map((param) => ({
object_id: param.object_id,
collab_type: Number(param.collab_type) as 0 | 1 | 2 | 3 | 4 | 5,
}))
)) as unknown as Map<string, { doc_state: number[] }>;
const result: Record<string, number[]> = {};
res.forEach((value, key) => {
result[key] = value.doc_state;
});
return result;
}
export async function getUserWorkspace(): Promise<UserWorkspace> {
const res = await client.get_user_workspace();
return {
visitingWorkspaceId: res.visiting_workspace_id,
workspaces: res.workspaces.map((workspace) => ({
id: workspace.workspace_id,
name: workspace.workspace_name,
icon: workspace.icon,
owner: {
id: Number(workspace.owner_uid),
name: workspace.owner_name,
},
type: workspace.workspace_type,
workspaceDatabaseId: workspace.database_storage_id,
})),
};
export async function getPublishViewMeta(publishNamespace: string, publishName: string) {
return client.get_publish_view_meta(publishNamespace, publishName);
}
@@ -1,16 +1,8 @@
import { YDoc } from '@/application/collab.type';
import { ProviderType, SignUpWithEmailPasswordParams, UserProfile } from '@/application/user.type';
import { ViewMeta } from '@/application/db/tables/view_metas';
import * as Y from 'yjs';
export interface AFService {
getDeviceID: () => string;
getClientID: () => string;
authService: AuthService;
userService: UserService;
documentService: DocumentService;
folderService: FolderService;
databaseService: DatabaseService;
}
export type AFService = PublishService;
export interface AFServiceConfig {
cloudConfig: AFCloudConfig;
@@ -22,35 +14,16 @@ export interface AFCloudConfig {
wsURL: string;
}
export interface AuthService {
getOAuthURL: (provider: ProviderType) => Promise<string>;
signInWithOAuth: (params: { uri: string }) => Promise<void>;
signupWithEmailPassword: (params: SignUpWithEmailPasswordParams) => Promise<void>;
signinWithEmailPassword: (email: string, password: string) => Promise<void>;
signOut: () => Promise<void>;
}
export interface DocumentService {
openDocument: (docId: string) => Promise<YDoc>;
}
export interface DatabaseService {
getWorkspaceDatabases: () => Promise<{ views: string[]; database_id: string }[]>;
openDatabase: (
databaseId: string,
rowIds?: string[]
export interface PublishService {
getPublishViewMeta: (namespace: string, publishName: string) => Promise<ViewMeta>;
getPublishView: (namespace: string, publishName: string) => Promise<YDoc>;
getPublishInfo: (viewId: string) => Promise<{ namespace: string; publishName: string }>;
getPublishDatabaseViewRows: (
namespace: string,
publishName: string,
rowIds: string[]
) => Promise<{
databaseDoc: YDoc;
rows: Y.Map<YDoc>;
destroy: () => void;
}>;
closeDatabase: (databaseId: string) => Promise<void>;
}
export interface UserService {
getUserProfile: () => Promise<UserProfile | null>;
checkUser: () => Promise<boolean>;
}
export interface FolderService {
openWorkspace: (workspaceId: string) => Promise<YDoc>;
}
@@ -1,114 +0,0 @@
import { AFCloudConfig, AuthService } from '@/application/services/services.type';
import {
AuthenticatorPB,
OauthProviderPB,
OauthSignInPB,
SignInPayloadPB,
SignUpPayloadPB,
UserEventGetOauthURLWithProvider,
UserEventOauthSignIn,
UserEventSignInWithEmailPassword,
UserEventSignOut,
UserEventSignUp,
UserProfilePB,
} from './backend/events/flowy-user';
import { ProviderType, SignUpWithEmailPasswordParams, UserProfile } from '@/application/user.type';
export class TauriAuthService implements AuthService {
constructor (private cloudConfig: AFCloudConfig, private clientConfig: {
deviceId: string;
clientId: string;
}) {}
getDeviceID = (): string => {
return this.clientConfig.deviceId;
};
getOAuthURL = async (provider: ProviderType): Promise<string> => {
const providerDataRes = await UserEventGetOauthURLWithProvider(
OauthProviderPB.fromObject({
provider: provider as number,
}),
);
if (!providerDataRes.ok) {
throw new Error(providerDataRes.val.msg);
}
const providerData = providerDataRes.val;
return providerData.oauth_url;
};
signInWithOAuth = async ({ uri }: { uri: string }): Promise<void> => {
const payload = OauthSignInPB.fromObject({
authenticator: AuthenticatorPB.AppFlowyCloud,
map: {
sign_in_url: uri,
device_id: this.getDeviceID(),
},
});
const res = await UserEventOauthSignIn(payload);
if (!res.ok) {
throw new Error(res.val.msg);
}
return;
};
signinWithEmailPassword = async (email: string, password: string): Promise<void> => {
const payload = SignInPayloadPB.fromObject({
email,
password,
});
const res = await UserEventSignInWithEmailPassword(payload);
if (!res.ok) {
return Promise.reject(res.val.msg);
}
return;
};
signupWithEmailPassword = async (params: SignUpWithEmailPasswordParams): Promise<void> => {
const payload = SignUpPayloadPB.fromObject({
name: params.name,
email: params.email,
password: params.password,
device_id: this.getDeviceID(),
});
const res = await UserEventSignUp(payload);
if (!res.ok) {
return Promise.reject(res.val.msg);
}
return;
};
signOut = async () => {
const res = await UserEventSignOut();
if (!res.ok) {
return Promise.reject(res.val.msg);
}
return;
};
}
export function parseUserProfileFrom (userPB: UserProfilePB): UserProfile {
const user = userPB.toObject();
return {
uid: user.id as number,
email: user.email,
name: user.name,
iconUrl: user.icon_url,
workspaceId: user.workspace_id,
};
}
@@ -1,24 +0,0 @@
import { YDoc } from '@/application/collab.type';
import { DatabaseService } from '@/application/services/services.type';
import * as Y from 'yjs';
export class TauriDatabaseService implements DatabaseService {
constructor() {
//
}
async getWorkspaceDatabases(): Promise<{ views: string[]; database_id: string }[]> {
return Promise.reject('Not implemented');
}
async closeDatabase(_databaseId: string) {
return Promise.reject('Not implemented');
}
async openDatabase(_viewId: string): Promise<{
databaseDoc: YDoc;
rows: Y.Map<YDoc>;
}> {
return Promise.reject('Not implemented');
}
}
@@ -1,8 +0,0 @@
import { DocumentService } from '@/application/services/services.type';
import * as Y from 'yjs';
export class TauriDocumentService implements DocumentService {
async openDocument(_id: string): Promise<Y.Doc> {
return Promise.reject('Not implemented');
}
}
@@ -1,12 +0,0 @@
import { YDoc } from '@/application/collab.type';
import { FolderService } from '@/application/services/services.type';
export class TauriFolderService implements FolderService {
constructor() {
//
}
async openWorkspace(_workspaceId: string): Promise<YDoc> {
return Promise.reject('Not implemented');
}
}
@@ -1,50 +1,24 @@
import {
AFService,
AFServiceConfig,
AuthService,
DatabaseService,
DocumentService,
FolderService,
UserService,
} from '@/application/services/services.type';
import { TauriAuthService } from '@/application/services/tauri-services/auth.service';
import { TauriDatabaseService } from '@/application/services/tauri-services/database.service';
import { TauriFolderService } from '@/application/services/tauri-services/folder.service';
import { TauriUserService } from '@/application/services/tauri-services/user.service';
import { TauriDocumentService } from '@/application/services/tauri-services/document.service';
import { AFService } from '@/application/services/services.type';
import { nanoid } from 'nanoid';
export class AFClientService implements AFService {
authService: AuthService;
userService: UserService;
documentService: DocumentService;
folderService: FolderService;
databaseService: DatabaseService;
private deviceId: string = nanoid(8);
private clientId: string = 'web';
private clientId: string = 'tauri';
getDeviceID = (): string => {
return this.deviceId;
};
async getPublishView(_namespace: string, _publishName: string) {
return Promise.reject('Method not implemented');
}
getClientID = (): string => {
return this.clientId;
};
async getPublishInfo(_viewId: string) {
return Promise.reject('Method not implemented');
}
constructor(config: AFServiceConfig) {
this.authService = new TauriAuthService(config.cloudConfig, {
deviceId: this.deviceId,
clientId: this.clientId,
});
this.userService = new TauriUserService();
this.documentService = new TauriDocumentService();
this.folderService = new TauriFolderService();
this.databaseService = new TauriDatabaseService();
async getPublishViewMeta(_namespace: string, _publishName: string) {
return Promise.reject('Method not implemented');
}
async getPublishDatabaseViewRows(_namespace: string, _publishName: string, _rowIds: string[]) {
return Promise.reject('Method not implemented');
}
}
@@ -1,20 +0,0 @@
import { UserService } from '@/application/services/services.type';
import { UserProfile } from '@/application/user.type';
import { UserEventGetUserProfile } from './backend/events/flowy-user';
import { parseUserProfileFrom } from '@/application/services/tauri-services/auth.service';
export class TauriUserService implements UserService {
async getUserProfile(): Promise<UserProfile | null> {
const res = await UserEventGetUserProfile();
if (res.ok) {
return parseUserProfileFrom(res.val);
}
return null;
}
async checkUser(): Promise<boolean> {
return Promise.resolve(false);
}
}
@@ -1,75 +0,0 @@
export enum Authenticator {
Local = 0,
Supabase = 1,
AppFlowyCloud = 2,
}
export enum EncryptionType {
NoEncryption = 0,
Symmetric = 1,
}
export interface UserProfile {
uid: number;
uuid?: string;
email?: string;
name?: string;
iconUrl?: string;
workspaceId?: string;
}
export interface UserWorkspace {
visitingWorkspaceId: string;
workspaces: Workspace[];
}
export interface Workspace {
id: string;
name: string;
icon: string;
owner: {
id: number;
name: string;
};
type: number;
workspaceDatabaseId: string;
}
export interface SignUpWithEmailPasswordParams {
name: string;
email: string;
password: string;
}
export enum ProviderType {
Apple = 0,
Azure = 1,
Bitbucket = 2,
Discord = 3,
Facebook = 4,
Figma = 5,
Github = 6,
Gitlab = 7,
Google = 8,
Keycloak = 9,
Kakao = 10,
Linkedin = 11,
Notion = 12,
Spotify = 13,
Slack = 14,
Workos = 15,
Twitch = 16,
Twitter = 17,
Email = 18,
Phone = 19,
Zoom = 20,
}
export interface UserSetting {
workspaceId: string;
latestView?: {
id: string;
name: string;
};
hasLatestView: boolean;
}
@@ -0,0 +1,5 @@
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Icons/ Arrow / right" opacity="0.5">
<path id="Vector 15" d="M4.5 9.375L7.875 6L4.5 2.625" stroke="#171717" stroke-width="0.9" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 291 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

@@ -1,23 +0,0 @@
import { YFolder } from '@/application/collab.type';
import { Crumb, FolderContext } from '@/application/folder-yjs';
export const FolderProvider: React.FC<{
folder: YFolder | null;
children?: React.ReactNode;
onNavigateToView?: (viewId: string) => void;
crumbs?: Crumb[];
setCrumbs?: React.Dispatch<React.SetStateAction<Crumb[]>>;
}> = ({ folder, children, onNavigateToView, crumbs, setCrumbs }) => {
return (
<FolderContext.Provider
value={{
folder,
onNavigateToView,
crumbs,
setCrumbs,
}}
>
{children}
</FolderContext.Provider>
);
};
@@ -1,17 +0,0 @@
import { useContext, createContext } from 'react';
export const IdContext = createContext<IdProviderProps | null>(null);
interface IdProviderProps {
objectId: string;
}
export const IdProvider = ({ children, ...props }: IdProviderProps & { children: React.ReactNode }) => {
return <IdContext.Provider value={props}>{children}</IdContext.Provider>;
};
const defaultIdValue = {} as IdProviderProps;
export function useId() {
return useContext(IdContext) || defaultIdValue;
}
@@ -1,33 +0,0 @@
import { getCurrentWorkspace } from 'src/application/services/js-services/session';
import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from '@mui/material';
import React from 'react';
import { useNavigate } from 'react-router-dom';
export function RecordNotFound({ open, title }: { open: boolean; title?: string }) {
const navigate = useNavigate();
return (
<Dialog open={open}>
<DialogTitle>Oops.. something went wrong</DialogTitle>
<DialogContent>
<DialogContentText id='alert-dialog-description'>
{title ? title : 'The record you are looking for does not exist.'}
</DialogContentText>
</DialogContent>
<DialogActions className={'flex w-full items-center justify-center'}>
<Button
onClick={async () => {
const workspace = await getCurrentWorkspace();
if (!workspace) return;
navigate(`/view/${workspace.id}`);
}}
>
Go back
</Button>
</DialogActions>
</Dialog>
);
}
export default RecordNotFound;
@@ -1 +0,0 @@
export * from './RecordNotFound';
@@ -1,27 +1,14 @@
import toast from 'react-hot-toast';
const commonOptions = {
style: {
background: 'var(--bg-base)',
color: 'var(--text-title)',
shadows: 'var(--shadow)',
},
};
export const notify = {
success: (message: string) => {
toast.success(message, commonOptions);
window.toast.success(message);
},
error: (message: string) => {
toast.error(message, commonOptions);
},
loading: (message: string) => {
toast.loading(message, commonOptions);
window.toast.error(message);
},
info: (message: string) => {
toast(message, commonOptions);
window.toast.info(message);
},
clear: () => {
toast.dismiss();
window.toast.clear();
},
};
@@ -1,30 +0,0 @@
import { YView } from '@/application/collab.type';
import { usePageInfo } from '@/components/_shared/page/usePageInfo';
import React from 'react';
export function Page({
id,
onClick,
...props
}: {
id: string;
onClick?: (view: YView) => void;
style?: React.CSSProperties;
}) {
const { view, icon, name } = usePageInfo(id);
return (
<div
onClick={() => {
onClick && view && onClick(view);
}}
className={'flex items-center justify-center gap-2 overflow-hidden'}
{...props}
>
<div>{icon}</div>
<div className={'flex-1 truncate'}>{name}</div>
</div>
);
}
export default Page;
@@ -1 +0,0 @@
export * from './Page';
@@ -1,92 +0,0 @@
import { FontLayout, LineHeightLayout, ViewLayout, YjsFolderKey, YView } from '@/application/collab.type';
import { useViewSelector } from '@/application/folder-yjs';
import { CoverType } from '@/application/folder-yjs/folder.type';
import React, { useEffect, useMemo, useState } from 'react';
import { ReactComponent as DocumentSvg } from '$icons/16x/document.svg';
import { ReactComponent as GridSvg } from '$icons/16x/grid.svg';
import { ReactComponent as BoardSvg } from '$icons/16x/board.svg';
import { ReactComponent as CalendarSvg } from '$icons/16x/date.svg';
import { useTranslation } from 'react-i18next';
export interface PageCover {
type: CoverType;
value: string;
}
export interface PageExtra {
cover: PageCover | null;
fontLayout: FontLayout;
lineHeightLayout: LineHeightLayout;
font?: string;
}
function parseExtra(extra: string): PageExtra {
let extraObj;
try {
extraObj = JSON.parse(extra);
} catch (e) {
extraObj = {};
}
return {
cover: extraObj.cover
? {
type: extraObj.cover.type,
value: extraObj.cover.value,
}
: null,
fontLayout: extraObj.font_layout || FontLayout.normal,
lineHeightLayout: extraObj.line_height_layout || LineHeightLayout.normal,
font: extraObj.font,
};
}
export function usePageInfo(id: string) {
const { view } = useViewSelector(id);
const [loading, setLoading] = useState(true);
const layout = view?.get(YjsFolderKey.layout);
const icon = view?.get(YjsFolderKey.icon);
const extra = view?.get(YjsFolderKey.extra);
const name = view?.get(YjsFolderKey.name) || '';
const iconObj = useMemo(() => {
try {
return JSON.parse(icon || '');
} catch (e) {
return null;
}
}, [icon]);
const extraObj = useMemo(() => {
return parseExtra(extra || '');
}, [extra]);
const defaultIcon = useMemo(() => {
switch (parseInt(layout ?? '0')) {
case ViewLayout.Document:
return <DocumentSvg />;
case ViewLayout.Grid:
return <GridSvg />;
case ViewLayout.Board:
return <BoardSvg />;
case ViewLayout.Calendar:
return <CalendarSvg />;
default:
return <DocumentSvg />;
}
}, [layout]);
const { t } = useTranslation();
useEffect(() => {
setLoading(!view);
}, [view]);
return {
icon: iconObj?.value || defaultIcon,
name: name || t('menuAppHeader.defaultNewPageName'),
view: view as YView,
loading,
extra: extraObj,
};
}
@@ -1,18 +1,12 @@
import FolderPage from '@/pages/FolderPage';
import PublishPage from '@/pages/PublishPage';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import ProtectedRoutes from '@/components/auth/ProtectedRoutes';
import LoginPage from '@/pages/LoginPage';
import ProductPage from '@/pages/ProductPage';
import withAppWrapper from '@/components/app/withAppWrapper';
import '@/styles/app.scss';
const AppMain = withAppWrapper(() => {
return (
<Routes>
<Route path={'/'} element={<ProtectedRoutes />}>
<Route path={'/view/:workspaceId'} element={<FolderPage />} />
<Route path={'/view/:workspaceId/:objectId'} element={<ProductPage />} />
</Route>
<Route path={'/login'} element={<LoginPage />} />
<Route path={'/:namespace/:publishName'} element={<PublishPage />} />
</Routes>
);
});
@@ -1,8 +1,27 @@
import { useAppLanguage } from '@/components/app/useAppLanguage';
import React, { createContext, useEffect, useMemo, useState } from 'react';
import { AFService } from '@/application/services/services.type';
import React, { createContext, useEffect, useState } from 'react';
import { AFService, AFServiceConfig } from '@/application/services/services.type';
import { getService } from '@/application/services';
import { useAppSelector } from '@/stores/store';
const defaultConfig: AFServiceConfig = {
cloudConfig: {
baseURL: import.meta.env.AF_BASE_URL
? import.meta.env.AF_BASE_URL
: import.meta.env.DEV
? 'https://test.appflowy.cloud'
: 'https://beta.appflowy.cloud',
gotrueURL: import.meta.env.AF_GOTRUE_URL
? import.meta.env.AF_GOTRUE_URL
: import.meta.env.DEV
? 'https://test.appflowy.cloud/gotrue'
: 'https://beta.appflowy.cloud/gotrue',
wsURL: import.meta.env.AF_WS_URL
? import.meta.env.AF_WS_URL
: import.meta.env.DEV
? 'wss://test.appflowy.cloud/ws/v1'
: 'wss://beta.appflowy.cloud/ws/v1',
},
};
export const AFConfigContext = createContext<
| {
@@ -12,7 +31,7 @@ export const AFConfigContext = createContext<
>(undefined);
function AppConfig({ children }: { children: React.ReactNode }) {
const appConfig = useAppSelector((state) => state.app.appConfig);
const [appConfig] = useState<AFServiceConfig>(defaultConfig);
const [service, setService] = useState<AFService>();
useAppLanguage();
@@ -24,14 +43,15 @@ function AppConfig({ children }: { children: React.ReactNode }) {
})();
}, [appConfig]);
const config = useMemo(
() => ({
service,
}),
[service]
return (
<AFConfigContext.Provider
value={{
service,
}}
>
{children}
</AFConfigContext.Provider>
);
return <AFConfigContext.Provider value={config}>{children}</AFConfigContext.Provider>;
}
export default AppConfig;
@@ -1,27 +1,52 @@
import { Provider } from 'react-redux';
import { store } from 'src/stores/store';
import { ErrorBoundary } from 'react-error-boundary';
import { ErrorHandlerPage } from 'src/components/error/ErrorHandlerPage';
import AppTheme from '@/components/app/AppTheme';
import { Toaster } from 'react-hot-toast';
import AppConfig from '@/components/app/AppConfig';
import { Suspense } from 'react';
import { Suspense, useEffect } from 'react';
import { SnackbarProvider, useSnackbar } from 'notistack';
export default function withAppWrapper (Component: React.FC): React.FC {
return function AppWrapper (): JSX.Element {
export default function withAppWrapper(Component: React.FC): React.FC {
return function AppWrapper(): JSX.Element {
const { enqueueSnackbar, closeSnackbar } = useSnackbar();
useEffect(() => {
window.toast = {
success: (message: string) => {
enqueueSnackbar(message, { variant: 'success' });
},
error: (message: string) => {
enqueueSnackbar(message, { variant: 'error' });
},
warning: (message: string) => {
enqueueSnackbar(message, { variant: 'warning' });
},
info: (message: string) => {
enqueueSnackbar(message, { variant: 'info' });
},
clear: () => {
closeSnackbar();
},
};
}, [closeSnackbar, enqueueSnackbar]);
return (
<Provider store={store}>
<AppTheme>
<ErrorBoundary FallbackComponent={ErrorHandlerPage}>
<AppTheme>
<ErrorBoundary FallbackComponent={ErrorHandlerPage}>
<SnackbarProvider
anchorOrigin={{
vertical: 'top',
horizontal: 'center',
}}
preventDuplicate
>
<AppConfig>
<Suspense>
<Component />
<Toaster />
</Suspense>
</AppConfig>
</ErrorBoundary>
</AppTheme>
</Provider>
</SnackbarProvider>
</ErrorBoundary>
</AppTheme>
);
};
}
}
@@ -1,70 +0,0 @@
import Button from '@mui/material/Button';
import GoogleIcon from '@/assets/settings/google.png';
import GithubIcon from '@/assets/settings/github.png';
import DiscordIcon from '@/assets/settings/discord.png';
import { useTranslation } from 'react-i18next';
import { useAuth } from './auth.hooks';
import { ProviderType } from '@/application/user.type';
import { useState } from 'react';
import EmailOutlined from '@mui/icons-material/EmailOutlined';
import SignInWithEmail from './SignInWithEmail';
export const LoginButtonGroup = () => {
const { t } = useTranslation();
const [openSignInWithEmail, setOpenSignInWithEmail] = useState(false);
const { signInWithProvider } = useAuth();
return (
<div className={'flex w-full flex-col items-center gap-4'}>
<Button
data-cy={'signInWithEmail'}
onClick={() => {
setOpenSignInWithEmail(true);
}}
className={'w-full rounded-lg border-text-title py-3 text-sm'}
color={'inherit'}
variant={'outlined'}
>
<EmailOutlined className={'mr-2 h-6 w-6'} />
{t('signIn.signInWithEmail')}
</Button>
<Button
disabled
onClick={() => {
void signInWithProvider(ProviderType.Google);
}}
className={'w-full rounded-lg border-text-title py-3 text-sm'}
color={'inherit'}
variant={'outlined'}
>
<img src={GoogleIcon} alt={'Google'} className={'mr-2 h-6 w-6'} />
{t('button.signInGoogle')}
</Button>
<Button
disabled
onClick={() => {
void signInWithProvider(ProviderType.Github);
}}
className={'w-full rounded-lg border-text-title py-3 text-sm'}
color={'inherit'}
variant={'outlined'}
>
<img src={GithubIcon} alt={'Github'} className={'mr-2 h-6 w-6'} />
{t('button.signInGithub')}
</Button>
<Button
disabled
onClick={() => {
void signInWithProvider(ProviderType.Discord);
}}
className={'w-full rounded-lg border-text-title py-3 text-sm'}
color={'inherit'}
variant={'outlined'}
>
<img src={DiscordIcon} alt={'Discord'} className={'mr-2 h-6 w-6'} />
{t('button.signInDiscord')}
</Button>
<SignInWithEmail open={openSignInWithEmail} onClose={() => setOpenSignInWithEmail(false)} />
</div>
);
};
@@ -1,97 +0,0 @@
import React, { lazy, useCallback, useEffect, useMemo, useState } from 'react';
import { useAuth } from '@/components/auth/auth.hooks';
import { currentUserActions, LoginState } from '@/stores/currentUser/slice';
import { useAppDispatch } from '@/stores/store';
import { getPlatform } from '@/utils/platform';
import SplashScreen from '@/components/auth/SplashScreen';
import CircularProgress from '@mui/material/CircularProgress';
import Portal from '@mui/material/Portal';
import { ReactComponent as Logo } from '@/assets/logo.svg';
import { useNavigate } from 'react-router-dom';
const TauriAuth = lazy(() => import('@/components/tauri/TauriAuth'));
function ProtectedRoutes() {
const { currentUser, checkUser, isReady } = useAuth();
const isLoading = currentUser?.loginState === LoginState.LOADING;
const [checked, setChecked] = useState(false);
const checkUserStatus = useCallback(async () => {
if (!isReady) return;
setChecked(false);
try {
if (!currentUser.isAuthenticated) {
await checkUser();
}
} finally {
setChecked(true);
}
}, [checkUser, isReady, currentUser.isAuthenticated]);
useEffect(() => {
void checkUserStatus();
}, [checkUserStatus]);
const platform = useMemo(() => getPlatform(), []);
const navigate = useNavigate();
if (checked && !currentUser.isAuthenticated && window.location.pathname !== '/login') {
navigate(`/login?redirect=${encodeURIComponent(window.location.pathname)}`);
return null;
}
if (currentUser.user?.workspaceId && (window.location.pathname === '/' || window.location.pathname === '')) {
navigate(`/view/${currentUser.user.workspaceId}`);
return null;
}
return (
<div
className={'relative h-screen w-screen bg-bg-body'}
style={{
overflow: 'hidden',
}}
>
{checked ? (
<SplashScreen />
) : (
<div className={'flex h-screen w-screen items-center justify-center'}>
<Logo className={'h-20 w-20'} />
</div>
)}
{isLoading && <StartLoading />}
{platform.isTauri && <TauriAuth />}
</div>
);
}
export default ProtectedRoutes;
const StartLoading = () => {
const dispatch = useAppDispatch();
useEffect(() => {
const preventDefault = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
dispatch(currentUserActions.resetLoginState());
}
};
document.addEventListener('keydown', preventDefault, true);
return () => {
document.removeEventListener('keydown', preventDefault, true);
};
}, [dispatch]);
return (
<Portal>
<div className={'bg-bg-mask fixed inset-0 z-[1400] flex h-full w-full items-center justify-center bg-opacity-50'}>
<CircularProgress />
</div>
</Portal>
);
};
@@ -1,83 +0,0 @@
import { Button, CircularProgress, Dialog, DialogActions, DialogContent, TextField } from '@mui/material';
import React, { useState } from 'react';
import { useAuth } from '@/components/auth/auth.hooks';
import { useTranslation } from 'react-i18next';
function SignInWithEmail({ open, onClose }: { open: boolean; onClose: () => void }) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const { signInWithEmailPassword } = useAuth();
const handleSignIn = async () => {
setLoading(true);
try {
await signInWithEmailPassword(email, password);
onClose();
} catch (e) {
// Handle error
}
setLoading(false);
};
return (
<Dialog
open={open}
onClose={onClose}
sx={{
zIndex: 1500,
}}
data-cy={'signInWithEmailDialog'}
PaperProps={{
className: 'w-[400px]',
}}
keepMounted={false}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
void handleSignIn();
}
}}
>
<DialogContent className={'mt-2 flex w-full flex-col gap-3'}>
<TextField
label={'Email'}
size={'small'}
data-cy={'email'}
required={true}
placeholder={'name@gmail.com'}
type={'email'}
onChange={(e) => setEmail(e.target.value)}
/>
<TextField
size={'small'}
data-cy={'password'}
required={true}
label={'Password'}
placeholder={'Password'}
type={'password'}
onChange={(e) => setPassword(e.target.value)}
/>
</DialogContent>
<DialogActions className={'mb-4 flex w-full gap-2 px-6'}>
<Button variant={'outlined'} className={'flex-1'} color={'inherit'} onClick={onClose}>
{t('button.cancel')}
</Button>
<Button
data-cy={'submit'}
disabled={loading}
className={'justify-content flex h-[33px] flex-1 items-center gap-2'}
variant={'contained'}
onClick={handleSignIn}
>
{loading && <CircularProgress size={20} />}
{t('button.signIn')}
</Button>
</DialogActions>
</Dialog>
);
}
export default SignInWithEmail;
@@ -1,14 +0,0 @@
import React from 'react';
import { Outlet } from 'react-router-dom';
import Layout from '@/components/layout/Layout';
function SplashScreen () {
return (
<Layout>
<Outlet/>
</Layout>
);
}
export default SplashScreen;
@@ -1,35 +0,0 @@
import React from 'react';
import Welcome from './Welcome';
import withAppWrapper from '@/components/app/withAppWrapper';
describe('<Welcome />', () => {
beforeEach(() => {
cy.mockAPI();
});
it('renders', () => {
const AppWrapper = withAppWrapper(Welcome);
cy.mount(<AppWrapper />);
});
it('should handle login success', () => {
const AppWrapper = withAppWrapper(Welcome);
cy.mount(<AppWrapper />);
cy.get('[data-cy=signInWithEmail]').click();
cy.wait(100);
cy.get('[data-cy=signInWithEmailDialog]').as('dialog').should('be.visible');
cy.get('[data-cy=email]').type('fakeEmail123');
cy.get('[data-cy=password]').type('fakePassword123');
cy.get('[data-cy=submit]').click();
cy.wait('@loginSuccess');
cy.wait('@verifyToken');
cy.wait('@getUserProfile');
cy.wait('@getUserWorkspace');
cy.get('@dialog').should('not.exist');
});
});
@@ -1,39 +0,0 @@
import { ReactComponent as AppflowyLogo } from '@/assets/logo.svg';
import { Stack } from '@mui/material';
import { useTranslation } from 'react-i18next';
import { LoginButtonGroup } from './LoginButtonGroup';
import { getPlatform } from '@/utils/platform';
import { lazy } from 'react';
const SignInAsAnonymous = lazy(() => import('@/components/tauri/SignInAsAnonymous'));
export const Welcome = () => {
const { t } = useTranslation();
return (
<>
<form onSubmit={(e) => e.preventDefault()} method='POST'>
<Stack className='relative flex h-screen w-screen flex-col items-center justify-center gap-12 bg-bg-body text-center text-text-title'>
<div className='flex justify-center' id='appflowy'>
<AppflowyLogo className={'h-16 w-16'} />
</div>
<div>
<span className='text-2xl font-semibold leading-9'>
{t('welcomeTo')} {t('appName')}
</span>
</div>
<div id='Get-Started' className='flex w-[340px] flex-col gap-4 ' aria-label='Get-Started'>
{getPlatform().isTauri && <SignInAsAnonymous />}
<div className={'w-w-full flex items-center justify-center gap-2 text-sm'}>
<LoginButtonGroup />
</div>
</div>
</Stack>
</form>
</>
);
};
export default Welcome;
@@ -1,192 +0,0 @@
import { useAppDispatch, useAppSelector } from '@/stores/store';
import { useCallback, useContext } from 'react';
import { nanoid } from 'nanoid';
import { open } from '@tauri-apps/api/shell';
import { ProviderType, UserProfile } from '@/application/user.type';
import { currentUserActions } from '@/stores/currentUser/slice';
import { AFConfigContext } from '@/components/app/AppConfig';
import { notify } from '@/components/_shared/notify';
export const useAuth = () => {
const dispatch = useAppDispatch();
const AFConfig = useContext(AFConfigContext);
const currentUser = useAppSelector((state) => state.currentUser);
const isReady = !!AFConfig?.service;
const handleSuccess = useCallback(() => {
notify.clear();
dispatch(currentUserActions.loginSuccess());
}, [dispatch]);
const setUser = useCallback(
async (userProfile: UserProfile) => {
handleSuccess();
dispatch(currentUserActions.updateUser(userProfile));
},
[dispatch, handleSuccess]
);
const handleStart = useCallback(() => {
notify.clear();
notify.loading('Loading...');
dispatch(currentUserActions.loginStart());
}, [dispatch]);
const handleError = useCallback(
({ message }: { message: string }) => {
notify.clear();
notify.error(message);
dispatch(currentUserActions.loginError());
},
[dispatch]
);
// Check if the user is authenticated
const checkUser = useCallback(async () => {
try {
const userHasSignIn = await AFConfig?.service?.userService.checkUser();
if (!userHasSignIn) {
throw new Error('Failed to check user');
}
const userProfile = await AFConfig?.service?.userService.getUserProfile();
if (!userProfile) {
throw new Error('Failed to check user');
}
console.log('userProfile', userProfile);
await setUser(userProfile);
return userProfile;
} catch (e) {
return Promise.reject('Failed to check user');
}
}, [AFConfig?.service?.userService, setUser]);
const register = useCallback(
async (email: string, password: string, name: string): Promise<UserProfile | null> => {
handleStart();
try {
const userProfile = await AFConfig?.service?.authService.signupWithEmailPassword({
email,
password,
name,
});
if (!userProfile) {
throw new Error('Failed to register');
}
await setUser(userProfile);
return userProfile;
} catch (e) {
handleError({
message: 'Failed to register',
});
return null;
}
},
[handleStart, AFConfig?.service?.authService, setUser, handleError]
);
const logout = useCallback(async () => {
try {
await AFConfig?.service?.authService.signOut();
dispatch(currentUserActions.logout());
} catch (e) {
handleError({
message: 'Failed to logout',
});
}
}, [AFConfig?.service?.authService, dispatch, handleError]);
const signInAsAnonymous = useCallback(async () => {
const fakeEmail = nanoid(8) + '@appflowy.io';
const fakePassword = 'AppFlowy123@';
const fakeName = 'Me';
await register(fakeEmail, fakePassword, fakeName);
}, [register]);
const signInWithProvider = useCallback(
async (provider: ProviderType) => {
handleStart();
try {
const url = await AFConfig?.service?.authService.getOAuthURL(provider);
if (!url) {
throw new Error();
}
await open(url);
} catch {
handleError({
message: 'Failed to sign in',
});
}
},
[AFConfig?.service?.authService, handleError, handleStart]
);
const signInWithOAuth = useCallback(
async (uri: string) => {
handleStart();
try {
await AFConfig?.service?.authService.signInWithOAuth({ uri });
const userProfile = await AFConfig?.service?.userService.getUserProfile();
if (!userProfile) {
throw new Error();
}
await setUser(userProfile);
return userProfile;
} catch (e) {
handleError({
message: 'Failed to sign in',
});
}
},
[AFConfig?.service?.authService, AFConfig?.service?.userService, handleError, handleStart, setUser]
);
const signInWithEmailPassword = useCallback(
async (email: string, password: string) => {
handleStart();
try {
await AFConfig?.service?.authService.signinWithEmailPassword(email, password);
const userProfile = await AFConfig?.service?.userService.getUserProfile();
if (!userProfile) {
throw new Error();
}
await setUser(userProfile);
return userProfile;
} catch (e) {
handleError({
message: 'Failed to sign in',
});
}
},
[AFConfig?.service?.authService, AFConfig?.service?.userService, handleError, handleStart, setUser]
);
return {
isReady,
currentUser,
checkUser,
register,
logout,
signInWithProvider,
signInAsAnonymous,
signInWithOAuth,
signInWithEmailPassword,
};
};
@@ -1,89 +0,0 @@
import { YDoc, YjsEditorKey } from '@/application/collab.type';
import { DatabaseContextState } from '@/application/database-yjs';
import { AFConfigContext } from '@/components/app/AppConfig';
import { Log } from '@/utils/log';
import { useCallback, useContext, useEffect, useState } from 'react';
export function useGetDatabaseId(iidIndex: string) {
const [databaseId, setDatabaseId] = useState<string>();
const databaseService = useContext(AFConfigContext)?.service?.databaseService;
const loadDatabaseId = useCallback(async () => {
if (!databaseService) return;
const databases = await databaseService.getWorkspaceDatabases();
console.log('databses', databases);
const id = databases.find((item) => item.views.includes(iidIndex))?.database_id;
if (!id) return;
setDatabaseId(id);
}, [iidIndex, databaseService]);
useEffect(() => {
void loadDatabaseId();
}, [loadDatabaseId]);
return databaseId;
}
export function useGetDatabaseDispatch() {
const databaseService = useContext(AFConfigContext)?.service?.databaseService;
const onOpenDatabase = useCallback(
async ({ databaseId, rowIds }: { databaseId: string; rowIds?: string[] }) => {
if (!databaseService) return Promise.reject();
return databaseService.openDatabase(databaseId, rowIds);
},
[databaseService]
);
const onCloseDatabase = useCallback(
(databaseId: string) => {
if (!databaseService) return;
void databaseService.closeDatabase(databaseId);
},
[databaseService]
);
return {
onOpenDatabase,
onCloseDatabase,
};
}
export function useLoadDatabase({ databaseId, rowIds }: { databaseId?: string; rowIds?: string[] }) {
const [doc, setDoc] = useState<YDoc | null>(null);
const [rows, setRows] = useState<DatabaseContextState['rowDocMap'] | null>(null); // Map<rowId, YDoc
const [notFound, setNotFound] = useState<boolean>(false);
const { onOpenDatabase, onCloseDatabase } = useGetDatabaseDispatch();
const handleOpenDatabase = useCallback(
async (databaseId: string, rowIds?: string[]) => {
try {
setDoc(null);
const { databaseDoc, rows } = await onOpenDatabase({
databaseId,
rowIds,
});
console.log('databaseDoc', databaseDoc.getMap(YjsEditorKey.data_section).toJSON());
console.log('rows', rows);
setDoc(databaseDoc);
setRows(rows);
} catch (e) {
Log.error(e);
setNotFound(true);
}
},
[onOpenDatabase]
);
useEffect(() => {
if (!databaseId) return;
void handleOpenDatabase(databaseId, rowIds);
return () => {
onCloseDatabase(databaseId);
};
}, [handleOpenDatabase, databaseId, rowIds, onCloseDatabase]);
return { doc, rows, notFound };
}
@@ -1,23 +1,103 @@
import { YDatabase, YDoc, YjsDatabaseKey, YjsEditorKey } from '@/application/collab.type';
import { ViewMeta } from '@/application/db/tables/view_metas';
import ComponentLoading from '@/components/_shared/progress/ComponentLoading';
import DatabaseRow from '@/components/database/DatabaseRow';
import DatabaseViews from '@/components/database/DatabaseViews';
import { ViewMetaPreview, ViewMetaProps } from '@/components/view-meta/ViewMetaPreview';
import React, { Suspense, useCallback, useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import * as Y from 'yjs';
import { DatabaseContextProvider } from './DatabaseContext';
import React, { memo } from 'react';
export interface Database2Props extends ViewMetaProps {
doc: YDoc;
getViewRowsMap?: (viewId: string, rowIds: string[]) => Promise<{ rows: Y.Map<YDoc>; destroy: () => void }>;
loadView?: (viewId: string) => Promise<YDoc>;
navigateToView?: (viewId: string) => Promise<void>;
loadViewMeta?: (viewId: string) => Promise<ViewMeta>;
}
export const Database = memo(
({
viewId,
onNavigateToView,
iidIndex,
}: {
iidIndex: string;
viewId: string;
onNavigateToView: (viewId: string) => void;
}) => {
return (
<div className='appflowy-database relative flex w-full flex-1 select-text flex-col overflow-y-hidden'>
<DatabaseViews iidIndex={iidIndex} onChangeView={onNavigateToView} viewId={viewId} />
</div>
);
function Database({ doc, getViewRowsMap, navigateToView, loadViewMeta, loadView, ...viewMeta }: Database2Props) {
const [search, setSearch] = useSearchParams();
const viewId = search.get('v') || viewMeta.viewId;
const rowIds = useMemo(() => {
if (!viewId) return [];
const database = doc.getMap(YjsEditorKey.data_section).get(YjsEditorKey.database) as YDatabase;
const rows = database.get(YjsDatabaseKey.views).get(viewId).get(YjsDatabaseKey.row_orders);
return rows.toArray().map((row) => row.get(YjsDatabaseKey.id));
}, [doc, viewId]);
const iidIndex = useMemo(() => {
const database = doc.getMap(YjsEditorKey.data_section).get(YjsEditorKey.database) as YDatabase;
return database.get(YjsDatabaseKey.metas).get(YjsDatabaseKey.iid);
}, [doc]);
const [rowDocMap, setRowDocMap] = useState<Y.Map<YDoc> | null>(null);
useEffect(() => {
if (!getViewRowsMap || !rowIds.length || !iidIndex) return;
void (async () => {
const { rows, destroy } = await getViewRowsMap(iidIndex, rowIds);
setRowDocMap(rows);
return destroy;
})();
}, [getViewRowsMap, rowIds, iidIndex]);
const rowId = search.get('r');
const handleChangeView = useCallback(
(viewId: string) => {
setSearch({ v: viewId });
},
[setSearch]
);
const handleNavigateToRow = useCallback(
(rowId: string) => {
setSearch({ r: rowId });
},
[setSearch]
);
if (!rowDocMap || !viewId) {
return null;
}
);
return (
<div className={'flex w-full justify-center'}>
<Suspense fallback={<ComponentLoading />}>
<DatabaseContextProvider
isDatabaseRowPage={!!rowId}
navigateToRow={handleNavigateToRow}
viewId={viewId}
databaseDoc={doc}
rowDocMap={rowDocMap}
readOnly={true}
loadView={loadView}
navigateToView={navigateToView}
loadViewMeta={loadViewMeta}
>
{rowId ? (
<DatabaseRow rowId={rowId} />
) : (
<div className={'relative flex h-full w-full flex-col'}>
{viewMeta && <ViewMetaPreview {...viewMeta} />}
<div className='appflowy-database relative flex w-full flex-1 select-text flex-col overflow-y-hidden'>
<DatabaseViews iidIndex={iidIndex} onChangeView={handleChangeView} viewId={viewId} />
</div>
</div>
)}
</DatabaseContextProvider>
</Suspense>
</div>
);
}
export default Database;
@@ -1,19 +0,0 @@
import { usePageInfo } from '@/components/_shared/page/usePageInfo';
import React from 'react';
function DatabaseTitle({ viewId }: { viewId: string }) {
const { name, icon } = usePageInfo(viewId);
return (
<div className={'flex w-full flex-col py-4'}>
<div className={'flex w-full items-center px-16 max-md:px-4'}>
<div className={'flex items-center gap-2 text-3xl'}>
<div>{icon}</div>
<div className={'font-bold'}>{name}</div>
</div>
</div>
</div>
);
}
export default DatabaseTitle;
@@ -1,11 +1,10 @@
import { renderDatabase } from '@/components/database/__tests__/withTestingDatabase';
import '@/components/layout/layout.scss';
import '@/styles/app.scss';
describe('<Database />', () => {
beforeEach(() => {
cy.viewport(1280, 720);
Object.defineProperty(window.navigator, 'language', { value: 'en-US' });
cy.mockDatabase();
});
it('renders with a database', () => {
@@ -18,7 +17,7 @@ describe('<Database />', () => {
onNavigateToView,
},
() => {
cy.get('[data-testid^=view-tab-]').should('have.length', 4);
cy.get('[data-testid^=view-tab-]').should('have.length', 10);
cy.get('.database-grid').should('exist');
cy.get('[data-testid=view-tab-e410747b-5f2f-45a0-b2f7-890ad3001355]').click();
@@ -27,11 +26,13 @@ describe('<Database />', () => {
cy.wait(800);
cy.get('[data-testid=view-tab-7d2148fc-cace-4452-9c5c-96e52e6bf8b5]').click();
cy.wait(800);
cy.get('.database-grid').should('exist');
cy.wrap(onNavigateToView).should('have.been.calledWith', '7d2148fc-cace-4452-9c5c-96e52e6bf8b5');
cy.wait(800);
cy.get('[data-testid=view-tab-2143e95d-5dcb-4e0f-bb2c-50944e6e019f]').click();
cy.wait(800);
cy.get('.database-calendar').should('exist');
cy.wrap(onNavigateToView).should('have.been.calledWith', '2143e95d-5dcb-4e0f-bb2c-50944e6e019f');
}
@@ -1,46 +1,39 @@
import { YDoc, YFolder, YjsEditorKey } from '@/application/collab.type';
import { YDoc } from '@/application/collab.type';
import { applyYDoc } from '@/application/ydoc/apply';
import { FolderProvider } from '@/components/_shared/context-provider/FolderProvider';
import { IdProvider } from '@/components/_shared/context-provider/IdProvider';
import withAppWrapper from '@/components/app/withAppWrapper';
import { DatabaseRow } from 'src/components/database/DatabaseRow';
import { DatabaseContextProvider } from 'src/components/database/DatabaseContext';
import * as Y from 'yjs';
import '@/components/layout/layout.scss';
import '@/styles/app.scss';
describe('<DatabaseRow />', () => {
beforeEach(() => {
cy.viewport(1280, 720);
Object.defineProperty(window.navigator, 'language', { value: 'en-US' });
Object.defineProperty(window.navigator, 'languages', { value: ['en-US'] });
cy.mockDatabase();
cy.mockDocument('f56bdf0f-90c8-53fb-97d9-ad5860d2b7a0');
});
it('renders with a row', () => {
cy.wait(1000);
cy.fixture('folder').then((folderJson) => {
Object.defineProperty(window.navigator, 'language', { value: 'en-US' });
cy.fixture('database/4c658817-20db-4f56-b7f9-0637a22dfeb6').then((database) => {
const doc = new Y.Doc();
const state = new Uint8Array(folderJson.data.doc_state);
const databaseState = new Uint8Array(database.data.doc_state);
applyYDoc(doc, state);
const folder = doc.getMap(YjsEditorKey.data_section).get(YjsEditorKey.folder) as YFolder;
applyYDoc(doc, databaseState);
cy.fixture('database/4c658817-20db-4f56-b7f9-0637a22dfeb6').then((database) => {
const doc = new Y.Doc();
const databaseState = new Uint8Array(database.data.doc_state);
cy.fixture('database/rows/4c658817-20db-4f56-b7f9-0637a22dfeb6').then((rows) => {
const rootRowsDoc = new Y.Doc();
const rowsFolder: Y.Map<YDoc> = rootRowsDoc.getMap();
const data = rows['2f944220-9f45-40d9-96b5-e8c0888daf7c'];
const rowDoc = new Y.Doc();
applyYDoc(doc, databaseState);
applyYDoc(rowDoc, new Uint8Array(data));
rowsFolder.set('2f944220-9f45-40d9-96b5-e8c0888daf7c', rowDoc);
cy.fixture('database/rows/4c658817-20db-4f56-b7f9-0637a22dfeb6').then((rows) => {
const rootRowsDoc = new Y.Doc();
const rowsFolder: Y.Map<YDoc> = rootRowsDoc.getMap();
const data = rows['2f944220-9f45-40d9-96b5-e8c0888daf7c'];
const rowDoc = new Y.Doc();
applyYDoc(rowDoc, new Uint8Array(data));
rowsFolder.set('2f944220-9f45-40d9-96b5-e8c0888daf7c', rowDoc);
cy.fixture('simple_doc').then((docJson) => {
const subDoc = new Y.Doc();
const state = new Uint8Array(docJson.data.doc_state);
applyYDoc(subDoc, state);
const AppWrapper = withAppWrapper(() => {
return (
<div className={'flex h-screen w-screen flex-col overflow-y-auto py-4'}>
@@ -48,8 +41,8 @@ describe('<DatabaseRow />', () => {
rowId={'2f944220-9f45-40d9-96b5-e8c0888daf7c'}
databaseDoc={doc}
rows={rowsFolder}
folder={folder}
viewId={'7d2148fc-cace-4452-9c5c-96e52e6bf8b5'}
loadView={() => Promise.resolve(subDoc)}
/>
</div>
);
@@ -70,28 +63,25 @@ function TestDatabaseRow({
rowId,
databaseDoc,
rows,
folder,
viewId,
loadView,
}: {
rowId: string;
databaseDoc: YDoc;
rows: Y.Map<YDoc>;
folder: YFolder;
viewId: string;
loadView?: (viewId: string) => Promise<YDoc>;
}) {
return (
<FolderProvider folder={folder}>
<IdProvider objectId={viewId}>
<DatabaseContextProvider
viewId={viewId}
readOnly={true}
isDatabaseRowPage
databaseDoc={databaseDoc}
rowDocMap={rows}
>
<DatabaseRow rowId={rowId} />
</DatabaseContextProvider>
</IdProvider>
</FolderProvider>
<DatabaseContextProvider
viewId={viewId}
readOnly={true}
isDatabaseRowPage
databaseDoc={databaseDoc}
rowDocMap={rows}
loadView={loadView}
>
<DatabaseRow rowId={rowId} />
</DatabaseContextProvider>
);
}
@@ -1,11 +1,10 @@
import { renderDatabase } from '@/components/database/__tests__/withTestingDatabase';
import '@/components/layout/layout.scss';
import '@/styles/app.scss';
describe('<Database /> with filters and sorts', () => {
beforeEach(() => {
cy.viewport(1280, 720);
Object.defineProperty(window.navigator, 'language', { value: 'en-US' });
cy.mockDatabase();
});
it('render a database with filters and sorts', () => {
@@ -1,12 +1,10 @@
import { YDoc, YFolder, YjsEditorKey } from '@/application/collab.type';
import { YDoc } from '@/application/collab.type';
import { applyYDoc } from '@/application/ydoc/apply';
import { FolderProvider } from '@/components/_shared/context-provider/FolderProvider';
import { IdProvider } from '@/components/_shared/context-provider/IdProvider';
import withAppWrapper from '@/components/app/withAppWrapper';
import { DatabaseContextProvider } from '@/components/database/DatabaseContext';
import DatabaseViews from '@/components/database/DatabaseViews';
import { useState } from 'react';
import * as Y from 'yjs';
import { Database } from 'src/components/database/Database';
export function renderDatabase(
{
@@ -20,49 +18,39 @@ export function renderDatabase(
},
onAfterRender?: () => void
) {
cy.fixture('folder').then((folderJson) => {
const doc = new Y.Doc();
const state = new Uint8Array(folderJson.data.doc_state);
cy.fixture(`database/${databaseId}`).then((database) => {
cy.fixture(`database/rows/${databaseId}`).then((rows) => {
const doc = new Y.Doc();
const rootRowsDoc = new Y.Doc();
const rowsFolder: Y.Map<YDoc> = rootRowsDoc.getMap();
const databaseState = new Uint8Array(database.data.doc_state);
applyYDoc(doc, state);
applyYDoc(doc, databaseState);
const folder = doc.getMap(YjsEditorKey.data_section).get(YjsEditorKey.folder) as YFolder;
Object.keys(rows).forEach((key) => {
const data = rows[key];
const rowDoc = new Y.Doc();
cy.fixture(`database/${databaseId}`).then((database) => {
cy.fixture(`database/rows/${databaseId}`).then((rows) => {
const doc = new Y.Doc();
const rootRowsDoc = new Y.Doc();
const rowsFolder: Y.Map<YDoc> = rootRowsDoc.getMap();
const databaseState = new Uint8Array(database.data.doc_state);
applyYDoc(doc, databaseState);
Object.keys(rows).forEach((key) => {
const data = rows[key];
const rowDoc = new Y.Doc();
applyYDoc(rowDoc, new Uint8Array(data));
rowsFolder.set(key, rowDoc);
});
const AppWrapper = withAppWrapper(() => {
return (
<div className={'flex h-screen w-screen flex-col py-4'}>
<TestDatabase
databaseDoc={doc}
rows={rowsFolder}
folder={folder}
iidIndex={viewId}
initialViewId={viewId}
onNavigateToView={onNavigateToView}
/>
</div>
);
});
cy.mount(<AppWrapper />);
onAfterRender?.();
applyYDoc(rowDoc, new Uint8Array(data));
rowsFolder.set(key, rowDoc);
});
const AppWrapper = withAppWrapper(() => {
return (
<div className={'flex h-screen w-screen flex-col py-4'}>
<TestDatabase
databaseDoc={doc}
rows={rowsFolder}
iidIndex={viewId}
initialViewId={viewId}
onNavigateToView={onNavigateToView}
/>
</div>
);
});
cy.mount(<AppWrapper />);
onAfterRender?.();
});
});
}
@@ -70,14 +58,12 @@ export function renderDatabase(
export function TestDatabase({
databaseDoc,
rows,
folder,
iidIndex,
initialViewId,
onNavigateToView,
}: {
databaseDoc: YDoc;
rows: Y.Map<YDoc>;
folder: YFolder;
iidIndex: string;
initialViewId: string;
onNavigateToView: (viewId: string) => void;
@@ -90,17 +76,13 @@ export function TestDatabase({
};
return (
<FolderProvider folder={folder}>
<IdProvider objectId={iidIndex}>
<DatabaseContextProvider
viewId={activeViewId || iidIndex}
databaseDoc={databaseDoc}
rowDocMap={rows}
readOnly={true}
>
<Database iidIndex={iidIndex} viewId={activeViewId} onNavigateToView={handleNavigateToView} />
</DatabaseContextProvider>
</IdProvider>
</FolderProvider>
<DatabaseContextProvider
viewId={activeViewId || iidIndex}
databaseDoc={databaseDoc}
rowDocMap={rows}
readOnly={true}
>
<DatabaseViews iidIndex={iidIndex} viewId={activeViewId} onChangeView={handleNavigateToView} />
</DatabaseContextProvider>
);
}
@@ -1,54 +1,28 @@
import { YDatabaseField, YDatabaseFields, YjsDatabaseKey, YjsEditorKey } from '@/application/collab.type';
import {
DatabaseContextState,
parseRelationTypeOption,
useDatabase,
useFieldSelector,
useNavigateToRow,
} from '@/application/database-yjs';
import { YjsDatabaseKey } from '@/application/collab.type';
import { DatabaseContext, DatabaseContextState, useDatabase, useNavigateToRow } from '@/application/database-yjs';
import { RelationCell, RelationCellData } from '@/application/database-yjs/cell.type';
import { RelationPrimaryValue } from '@/components/database/components/cell/relation/RelationPrimaryValue';
import { useGetDatabaseDispatch } from '@/components/database/Database.hooks';
import React, { useEffect, useMemo, useState } from 'react';
import React, { useContext, useEffect, useMemo, useState } from 'react';
function RelationItems({ style, cell, fieldId }: { cell: RelationCell; fieldId: string; style?: React.CSSProperties }) {
const { field } = useFieldSelector(fieldId);
const currentDatabaseId = useDatabase()?.get(YjsDatabaseKey.id);
const { onOpenDatabase, onCloseDatabase } = useGetDatabaseDispatch();
function RelationItems({ style, cell }: { cell: RelationCell; fieldId: string; style?: React.CSSProperties }) {
const database = useDatabase();
const viewId = database.get(YjsDatabaseKey.metas)?.get(YjsDatabaseKey.iid)?.toString();
const rowIds = useMemo(() => {
return (cell.data?.toJSON() as RelationCellData) ?? [];
}, [cell.data]);
const databaseId = rowIds.length > 0 && field ? parseRelationTypeOption(field).database_id : undefined;
const [databasePrimaryFieldId, setDatabasePrimaryFieldId] = useState<string | undefined>(undefined);
const getViewRowsMap = useContext(DatabaseContext)?.getViewRowsMap;
const [rows, setRows] = useState<DatabaseContextState['rowDocMap'] | null>();
const navigateToRow = useNavigateToRow();
useEffect(() => {
if (!databaseId || !rowIds.length) return;
void onOpenDatabase({ databaseId, rowIds }).then(({ databaseDoc: doc, rows }) => {
const fields = doc
.getMap(YjsEditorKey.data_section)
.get(YjsEditorKey.database)
.get(YjsDatabaseKey.fields) as YDatabaseFields;
fields.forEach((field, fieldId) => {
if ((field as YDatabaseField).get(YjsDatabaseKey.is_primary)) {
setDatabasePrimaryFieldId(fieldId);
}
});
if (!viewId || !rowIds.length) return;
void getViewRowsMap?.(viewId, rowIds).then(({ rows }) => {
setRows(rows);
});
}, [onOpenDatabase, databaseId, rowIds, onCloseDatabase]);
useEffect(() => {
return () => {
if (currentDatabaseId !== databaseId && databaseId) {
onCloseDatabase(databaseId);
}
};
}, [databaseId, currentDatabaseId, onCloseDatabase]);
}, [getViewRowsMap, rowIds, viewId]);
return (
<div style={style} className={'relation-cell flex w-full items-center gap-2'}>
@@ -64,9 +38,7 @@ function RelationItems({ style, cell, fieldId }: { cell: RelationCell; fieldId:
}}
className={'w-full cursor-pointer underline'}
>
{rowDoc && databasePrimaryFieldId && (
<RelationPrimaryValue rowDoc={rowDoc} fieldId={databasePrimaryFieldId} />
)}
{rowDoc && <RelationPrimaryValue rowDoc={rowDoc} />}
</div>
);
})}
Loaded 100 of 191 files, more files were not shown because too many files have changed in this diff. Show more