feat: publish databse to Web (#5709)

* chore: refactor the publish code

* feat: integrate publish into database page

* feat: add publish database structure

* feat: add database row collab

* feat: publish the database row collabs

* chore: update collab

* chore: improve question bubble

* feat: publish the database relations

* fix: rust ci

* feat: select grid view to publish

* feat: unable to deselect the primary database

* feat: optimize the read recent views speed (#5726)

* feat: optimize the read recent views speed

* fix: order of recent views should be from the latest to the oldest

* chore: update translations

* fix: replace the unable to be selected icon

* feat: remove left padding of inline database

* fix: code review

* chore: remove publish api err log

* chore: read the database collab and document collab from disk instead of memory

* chore: code cleanup

* chore: revert beta.appflowy.com

* chore: code cleanup

* test: add database encode test

* test: add publish database test

* chore: refresh sidebar layout

* chore: update comments
This commit is contained in:
Lucas.Xu 2024-07-22 13:35:42 +08:00 committed by GitHub
parent e8e4162a5c
commit 432db0f6d5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
61 changed files with 1593 additions and 816 deletions

View File

@ -1,6 +1,6 @@
import 'dart:io';
import 'package:appflowy/plugins/document/presentation/share/share_button.dart';
import 'package:appflowy/plugins/shared/share/share_button.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:path/path.dart' as p;
@ -51,9 +51,8 @@ void main() {
},
);
final shareButton = find.byType(DocumentShareButton);
final shareButtonState =
tester.widget(shareButton) as DocumentShareButton;
final shareButton = find.byType(ShareButton);
final shareButtonState = tester.widget(shareButton) as ShareButton;
final path = await mockSaveFilePath(
p.join(

View File

@ -1,17 +1,12 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:appflowy/core/config/kv.dart';
import 'package:appflowy/core/config/kv_keys.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/presentation.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/emoji_picker_button.dart';
import 'package:appflowy/plugins/document/presentation/share/share_button.dart';
import 'package:appflowy/plugins/shared/share/share_button.dart';
import 'package:appflowy/shared/feature_flags.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/presentation/screens/screens.dart';
@ -35,6 +30,10 @@ 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:flowy_infra_ui/widget/buttons/primary_button.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'emoji.dart';
@ -259,7 +258,7 @@ extension CommonOperations on WidgetTester {
/// Tap the share button above the document page.
Future<void> tapShareButton() async {
final shareButton = find.byWidgetPredicate(
(widget) => widget is DocumentShareButton,
(widget) => widget is ShareButton,
);
await tapButton(shareButton);
}

View File

@ -1,5 +1,3 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/plugins/ai_chat/chat_page.dart';
import 'package:appflowy/plugins/util.dart';
@ -9,6 +7,7 @@ import 'package:appflowy/workspace/presentation/home/home_stack.dart';
import 'package:appflowy/workspace/presentation/widgets/tab_bar_item.dart';
import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class AIChatPluginBuilder extends PluginBuilder {
@ -96,6 +95,7 @@ class AIChatPagePluginWidgetBuilder extends PluginWidgetBuilder
Widget buildWidget({
required PluginContext context,
required bool shrinkWrap,
Map<String, dynamic>? data,
}) {
notifier.isDeleted.addListener(() {
final deletedView = notifier.isDeleted.value;

View File

@ -54,6 +54,7 @@ class BlankPagePluginWidgetBuilder extends PluginWidgetBuilder
Widget buildWidget({
required PluginContext context,
required bool shrinkWrap,
Map<String, dynamic>? data,
}) =>
const BlankPage();

View File

@ -1,6 +1,3 @@
import 'package:appflowy/workspace/application/view/view_bloc.dart';
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/application/row/row_service.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/calculations/calculations_row.dart';
@ -9,6 +6,7 @@ import 'package:appflowy/plugins/database/tab_bar/desktop/setting_menu.dart';
import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart';
import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart';
import 'package:appflowy/workspace/application/action_navigation/navigation_action.dart';
import 'package:appflowy/workspace/application/view/view_bloc.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:collection/collection.dart';
@ -16,6 +14,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/scrolling/styled_scrollview.dart';
import 'package:flowy_infra_ui/widget/error_page.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:linked_scroll_controller/linked_scroll_controller.dart';
@ -24,7 +23,6 @@ import '../../application/row/row_controller.dart';
import '../../tab_bar/tab_bar_view.dart';
import '../../widgets/row/row_detail.dart';
import '../application/grid_bloc.dart';
import 'grid_scroll.dart';
import 'layout/layout.dart';
import 'layout/sizes.dart';
@ -504,7 +502,10 @@ class _PositionedCalculationsRowState
left: 0,
right: 0,
child: Container(
margin: EdgeInsets.only(left: GridSize.horizontalHeaderPadding + 40),
margin: EdgeInsets.only(
left:
context.read<DatabasePluginWidgetBuilderSize>().horizontalPadding,
),
padding: const EdgeInsets.only(bottom: 10),
decoration: BoxDecoration(
color: Theme.of(context).canvasColor,

View File

@ -4,6 +4,7 @@ import 'package:appflowy/plugins/database/application/field/field_controller.dar
import 'package:appflowy/plugins/database/domain/field_service.dart';
import 'package:appflowy/plugins/database/grid/application/grid_bloc.dart';
import 'package:appflowy/plugins/database/grid/application/grid_header_bloc.dart';
import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
import 'package:easy_localization/easy_localization.dart';
@ -139,7 +140,9 @@ class _GridHeaderState extends State<_GridHeader> {
}
Widget _cellLeading() {
return SizedBox(width: GridSize.horizontalHeaderPadding + 40);
return SizedBox(
width: context.read<DatabasePluginWidgetBuilderSize>().horizontalPadding,
);
}
}

View File

@ -1,5 +1,3 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import "package:appflowy/generated/locale_keys.g.dart";
import 'package:appflowy/plugins/database/application/cell/cell_controller.dart';
@ -7,18 +5,19 @@ import 'package:appflowy/plugins/database/application/field/field_controller.dar
import 'package:appflowy/plugins/database/application/row/row_controller.dart';
import 'package:appflowy/plugins/database/application/row/row_service.dart';
import 'package:appflowy/plugins/database/grid/application/row/row_bloc.dart';
import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart';
import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:provider/provider.dart';
import '../../../../widgets/row/accessory/cell_accessory.dart';
import '../../../../widgets/row/cells/cell_container.dart';
import '../../layout/sizes.dart';
import 'action.dart';
class GridRow extends StatefulWidget {
@ -112,7 +111,9 @@ class _RowLeadingState extends State<_RowLeading> {
child: Consumer<RegionStateNotifier>(
builder: (context, state, _) {
return SizedBox(
width: GridSize.horizontalHeaderPadding + 40,
width: context
.read<DatabasePluginWidgetBuilderSize>()
.horizontalPadding,
child: state.onEnter ? _activeWidget() : null,
);
},

View File

@ -1,7 +1,7 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/application/tab_bar_bloc.dart';
import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart';
import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
@ -17,14 +17,17 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'tab_bar_add_button.dart';
class TabBarHeader extends StatelessWidget {
const TabBarHeader({super.key});
const TabBarHeader({
super.key,
});
@override
Widget build(BuildContext context) {
return Container(
height: 30,
padding: EdgeInsets.symmetric(
horizontal: GridSize.horizontalHeaderPadding + 40,
horizontal:
context.read<DatabasePluginWidgetBuilderSize>().horizontalPadding,
),
child: Stack(
children: [

View File

@ -1,6 +1,7 @@
import 'package:appflowy/plugins/database/application/database_controller.dart';
import 'package:appflowy/plugins/database/application/tab_bar_bloc.dart';
import 'package:appflowy/plugins/database/widgets/share_button.dart';
import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart';
import 'package:appflowy/plugins/shared/share/share_button.dart';
import 'package:appflowy/plugins/util.dart';
import 'package:appflowy/startup/plugin/plugin.dart';
import 'package:appflowy/workspace/application/view/view_bloc.dart';
@ -16,6 +17,7 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:provider/provider.dart';
import 'desktop/tab_bar_header.dart';
import 'mobile/mobile_tab_bar_header.dart';
@ -219,6 +221,16 @@ class DatabaseTabBarViewPlugin extends Plugin {
}
}
const kDatabasePluginWidgetBuilderHorizontalPadding = 'horizontal_padding';
class DatabasePluginWidgetBuilderSize {
const DatabasePluginWidgetBuilderSize({
required this.horizontalPadding,
});
final double horizontalPadding;
}
class DatabasePluginWidgetBuilder extends PluginWidgetBuilder {
DatabasePluginWidgetBuilder({
required this.bloc,
@ -244,6 +256,7 @@ class DatabasePluginWidgetBuilder extends PluginWidgetBuilder {
Widget buildWidget({
required PluginContext context,
required bool shrinkWrap,
Map<String, dynamic>? data,
}) {
notifier.isDeleted.addListener(() {
final deletedView = notifier.isDeleted.value;
@ -252,11 +265,20 @@ class DatabasePluginWidgetBuilder extends PluginWidgetBuilder {
}
});
return DatabaseTabBarView(
key: ValueKey(notifier.view.id),
view: notifier.view,
shrinkWrap: shrinkWrap,
initialRowId: initialRowId,
final horizontalPadding =
data?[kDatabasePluginWidgetBuilderHorizontalPadding] as double? ??
GridSize.horizontalHeaderPadding + 40;
return Provider(
create: (context) => DatabasePluginWidgetBuilderSize(
horizontalPadding: horizontalPadding,
),
child: DatabaseTabBarView(
key: ValueKey(notifier.view.id),
view: notifier.view,
shrinkWrap: shrinkWrap,
initialRowId: initialRowId,
),
);
}
@ -270,7 +292,7 @@ class DatabasePluginWidgetBuilder extends PluginWidgetBuilder {
value: bloc,
child: Row(
children: [
DatabaseShareButton(key: ValueKey(view.id), view: view),
ShareButton(key: ValueKey(view.id), view: view),
const HSpace(10),
ViewFavoriteButton(view: view),
const HSpace(4),

View File

@ -1,9 +1,9 @@
import 'package:flutter/material.dart';
import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart';
import 'package:appflowy/startup/plugin/plugin.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/application/view/view_listener.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:flutter/material.dart';
class DatabaseViewWidget extends StatefulWidget {
const DatabaseViewWidget({
@ -55,6 +55,9 @@ class _DatabaseViewWidgetState extends State<DatabaseViewWidget> {
builder: (_, __, ___) => viewPlugin.widgetBuilder.buildWidget(
shrinkWrap: widget.shrinkWrap,
context: PluginContext(),
data: {
kDatabasePluginWidgetBuilderHorizontalPadding: 40.0,
},
),
);
}

View File

@ -104,6 +104,7 @@ class DatabaseDocumentPluginWidgetBuilder extends PluginWidgetBuilder
Widget buildWidget({
required PluginContext context,
required bool shrinkWrap,
Map<String, dynamic>? data,
}) {
return BlocBuilder<DocumentAppearanceCubit, DocumentAppearance>(
builder: (_, state) => DatabaseDocumentPage(

View File

@ -1,3 +1,3 @@
export '../../shared/share/share_bloc.dart';
export 'document_bloc.dart';
export 'document_service.dart';
export 'document_share_bloc.dart';

View File

@ -5,7 +5,7 @@ import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart';
import 'package:appflowy/plugins/document/document_page.dart';
import 'package:appflowy/plugins/document/presentation/document_collaborators.dart';
import 'package:appflowy/plugins/document/presentation/share/share_button.dart';
import 'package:appflowy/plugins/shared/share/share_button.dart';
import 'package:appflowy/plugins/util.dart';
import 'package:appflowy/shared/feature_flags.dart';
import 'package:appflowy/startup/plugin/plugin.dart';
@ -109,6 +109,7 @@ class DocumentPluginWidgetBuilder extends PluginWidgetBuilder
Widget buildWidget({
required PluginContext context,
required bool shrinkWrap,
Map<String, dynamic>? data,
}) {
notifier.isDeleted.addListener(() {
final deletedView = notifier.isDeleted.value;
@ -154,7 +155,7 @@ class DocumentPluginWidgetBuilder extends PluginWidgetBuilder
const HSpace(16),
]
: [const HSpace(8)],
DocumentShareButton(
ShareButton(
key: ValueKey('share_button_${view.id}'),
view: view,
),

View File

@ -13,9 +13,7 @@ import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/icon_button.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
class BuiltInPageWidget extends StatefulWidget {
@ -127,7 +125,6 @@ class _BuiltInPageWidgetState extends State<BuiltInPageWidget> {
icon: const FlowySvg(
FlowySvgs.information_s,
),
iconColorOnHover: Theme.of(context).colorScheme.onSecondary,
),
// setting
const Space(7, 0),
@ -141,7 +138,6 @@ class _BuiltInPageWidgetState extends State<BuiltInPageWidget> {
width: 24,
height: 24,
iconPadding: const EdgeInsets.all(3),
iconColorOnHover: Theme.of(context).colorScheme.onSecondary,
icon: const FlowySvg(
FlowySvgs.settings_s,
),

View File

@ -1,284 +0,0 @@
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/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/plugins/document/presentation/share/pubish_color_extension.dart';
import 'package:appflowy/plugins/document/presentation/share/publish_name_generator.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.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/style_widget/hover.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) {
_showToast(context, state);
},
builder: (context, state) {
return state.isPublished
? _PublishedWidget(
url: state.url,
onVisitSite: () {},
onUnPublish: () {
context
.read<DocumentShareBloc>()
.add(const DocumentShareEvent.unPublish());
},
)
: _UnPublishWidget(
onPublish: () async {
final id = context.read<DocumentShareBloc>().view.id;
final publishName = await generatePublishName(
id,
state.viewName,
);
if (context.mounted) {
context.read<DocumentShareBloc>().add(
DocumentShareEvent.publish('', publishName),
);
}
},
);
},
);
}
void _showToast(BuildContext context, DocumentShareState state) {
if (state.publishResult != null) {
state.publishResult!.fold(
(value) => showToastNotification(
context,
message: LocaleKeys.publish_publishSuccessfully.tr(),
),
(error) => showToastNotification(
context,
message: '${LocaleKeys.publish_publishFailed.tr()}: ${error.code}',
),
);
} else if (state.unpublishResult != null) {
state.unpublishResult!.fold(
(value) => showToastNotification(
context,
message: LocaleKeys.publish_unpublishSuccessfully.tr(),
),
(error) => showToastNotification(
context,
message: LocaleKeys.publish_unpublishFailed.tr(),
description: error.msg,
),
);
}
}
}
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) {
getIt<ClipboardService>().setData(
ClipboardServiceData(plainText: url),
);
showToastNotification(
context,
message: LocaleKeys.grid_url_copy.tr(),
);
},
onSubmitted: (url) {},
),
const VSpace(16),
Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildUnpublishButton(),
const Spacer(),
_buildVisitSiteButton(),
],
),
],
);
}
Widget _buildUnpublishButton() {
return SizedBox(
height: 36,
width: 184,
child: FlowyButton(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
border: Border.all(color: ShareMenuColors.borderColor(context)),
),
radius: BorderRadius.circular(10),
text: FlowyText.regular(
LocaleKeys.shareAction_unPublish.tr(),
textAlign: TextAlign.center,
),
onTap: widget.onUnPublish,
),
);
}
Widget _buildVisitSiteButton() {
return RoundedTextButton(
onPressed: () {
safeLaunchUrl(controller.text);
},
title: LocaleKeys.shareAction_visitSite.tr(),
width: 184,
height: 36,
borderRadius: const BorderRadius.all(Radius.circular(10)),
fillColor: Theme.of(context).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onPrimary,
);
}
}
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(
readOnly: true,
autoFocus: false,
controller: controller,
enableBorderColor: ShareMenuColors.borderColor(context),
suffixIcon: _buildCopyLinkIcon(context),
),
);
}
Widget _buildCopyLinkIcon(BuildContext context) {
return FlowyHover(
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,
),
),
),
);
}
}

View File

@ -1,248 +0,0 @@
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';
import 'package:appflowy/workspace/application/view/view_listener.dart';
import 'package:appflowy/workspace/presentation/home/toast.dart';
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.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_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';
class DocumentShareButton extends StatelessWidget {
const DocumentShareButton({
super.key,
required this.view,
});
final ViewPB view;
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => getIt<DocumentShareBloc>(param1: view)
..add(const DocumentShareEvent.initial()),
child: BlocListener<DocumentShareBloc, DocumentShareState>(
listener: (context, state) {
if (state.isLoading == false && state.exportResult != null) {
state.exportResult!.fold(
(data) => _handleExportData(context, data),
_handleExportError,
);
}
},
child: BlocBuilder<DocumentShareBloc, DocumentShareState>(
builder: (context, state) {
final tabs = [
if (state.enablePublish) ShareMenuTab.publish,
ShareMenuTab.exportAs,
];
final shareBloc = context.read<DocumentShareBloc>();
return SizedBox(
height: 32.0,
child: IntrinsicWidth(
child: AppFlowyPopover(
direction: PopoverDirection.bottomWithRightAligned,
constraints: const BoxConstraints(
maxWidth: 422,
),
offset: const Offset(0, 8),
popupBuilder: (context) => BlocProvider.value(
value: shareBloc,
child: ShareMenu(
tabs: tabs,
),
),
child: const InnerDocumentShareButton(),
),
),
);
},
),
),
);
}
void _handleExportData(BuildContext context, ExportDataPB exportData) {
switch (exportData.exportType) {
case ExportType.Markdown:
showSnackBarMessage(
context,
LocaleKeys.settings_files_exportFileSuccess.tr(),
);
break;
case ExportType.Link:
case ExportType.Text:
break;
case ExportType.HTML:
showSnackBarMessage(
context,
LocaleKeys.settings_files_exportFileSuccess.tr(),
);
break;
}
}
void _handleExportError(FlowyError error) {
showMessageToast(error.msg);
}
}
class InnerDocumentShareButton extends StatelessWidget {
const InnerDocumentShareButton({super.key});
@override
Widget build(BuildContext context) {
return RoundedTextButton(
title: LocaleKeys.shareAction_buttonText.tr(),
padding: const EdgeInsets.symmetric(horizontal: 14.0),
fontSize: 14.0,
fontWeight: FontWeight.w500,
borderRadius: const BorderRadius.all(
Radius.circular(10.0),
),
textColor: Theme.of(context).colorScheme.onPrimary,
);
}
}
class ShareActionList extends StatefulWidget {
const ShareActionList({
super.key,
required this.view,
});
final ViewPB view;
@override
State<ShareActionList> createState() => ShareActionListState();
}
@visibleForTesting
class ShareActionListState extends State<ShareActionList> {
late String name;
late final ViewListener viewListener = ViewListener(viewId: widget.view.id);
@override
void initState() {
super.initState();
listenOnViewUpdated();
}
@override
void dispose() {
viewListener.stop();
super.dispose();
}
@override
Widget build(BuildContext context) {
final docShareBloc = context.read<DocumentShareBloc>();
return PopoverActionList<ShareActionWrapper>(
direction: PopoverDirection.bottomWithCenterAligned,
offset: const Offset(0, 8),
actions: ShareAction.values
.map((action) => ShareActionWrapper(action))
.toList(),
buildChild: (controller) => Listener(
onPointerDown: (_) => controller.show(),
child: RoundedTextButton(
title: LocaleKeys.shareAction_buttonText.tr(),
padding: const EdgeInsets.symmetric(horizontal: 12.0),
onPressed: () {},
fontSize: 14.0,
textColor: Theme.of(context).colorScheme.onPrimary,
),
),
onSelected: (action, controller) async {
switch (action.inner) {
case ShareAction.markdown:
final exportPath = await getIt<FilePickerService>().saveFile(
dialogTitle: '',
// encode the file name in case it contains special characters
fileName: '${name.toFileName()}.md',
);
if (exportPath != null) {
docShareBloc.add(
DocumentShareEvent.share(
DocumentShareType.markdown,
exportPath,
),
);
}
break;
case ShareAction.html:
final exportPath = await getIt<FilePickerService>().saveFile(
dialogTitle: '',
fileName: '${name.toFileName()}.html',
);
if (exportPath != null) {
docShareBloc.add(
DocumentShareEvent.share(
DocumentShareType.html,
exportPath,
),
);
}
break;
case ShareAction.clipboard:
final documentExporter = DocumentExporter(widget.view);
final result =
await documentExporter.export(DocumentExportType.markdown);
result.fold(
(markdown) => getIt<ClipboardService>()
.setData(ClipboardServiceData(plainText: markdown)),
(error) => showMessageToast(error.msg),
);
break;
}
controller.close();
},
);
}
void listenOnViewUpdated() {
name = widget.view.name;
viewListener.start(
onViewUpdated: (view) {
name = view.name;
},
);
}
}
enum ShareAction {
markdown,
html,
clipboard,
}
class ShareActionWrapper extends ActionCell {
ShareActionWrapper(this.inner);
final ShareAction inner;
Widget? icon(Color iconColor) => null;
@override
String get name {
switch (inner) {
case ShareAction.markdown:
return LocaleKeys.shareAction_markdown.tr();
case ShareAction.html:
return LocaleKeys.shareAction_html.tr();
case ShareAction.clipboard:
return LocaleKeys.shareAction_clipboard.tr();
}
}
}

View File

@ -0,0 +1,68 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/application/tab_bar_bloc.dart';
import 'package:appflowy/plugins/shared/share/share_bloc.dart';
import 'package:appflowy/plugins/shared/share/share_menu.dart';
import 'package:appflowy_popover/appflowy_popover.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 ShareMenuButton extends StatelessWidget {
const ShareMenuButton({
super.key,
required this.tabs,
});
final List<ShareMenuTab> tabs;
@override
Widget build(BuildContext context) {
final shareBloc = context.read<ShareBloc>();
final databaseBloc = context.read<DatabaseTabBarBloc?>();
return SizedBox(
height: 32.0,
child: IntrinsicWidth(
child: AppFlowyPopover(
direction: PopoverDirection.bottomWithRightAligned,
constraints: const BoxConstraints(
maxWidth: 422,
),
offset: const Offset(0, 8),
popupBuilder: (context) => MultiBlocProvider(
providers: [
if (databaseBloc != null)
BlocProvider.value(
value: databaseBloc,
),
BlocProvider.value(value: shareBloc),
],
child: ShareMenu(
tabs: tabs,
),
),
child: const _ShareButton(),
),
),
);
}
}
class _ShareButton extends StatelessWidget {
const _ShareButton();
@override
Widget build(BuildContext context) {
return RoundedTextButton(
title: LocaleKeys.shareAction_buttonText.tr(),
padding: const EdgeInsets.symmetric(horizontal: 14.0),
fontSize: 14.0,
fontWeight: FontWeight.w500,
borderRadius: const BorderRadius.all(
Radius.circular(10.0),
),
textColor: Theme.of(context).colorScheme.onPrimary,
);
}
}

View File

@ -1,12 +1,13 @@
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/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/plugins/shared/share/share_bloc.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/util/string_extension.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy/workspace/application/export/document_exporter.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.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';
@ -20,6 +21,16 @@ class ExportTab extends StatelessWidget {
@override
Widget build(BuildContext context) {
final view = context.read<ShareBloc>().view;
if (view.layout == ViewLayoutPB.Document) {
return _buildDocumentExportTab(context);
}
return _buildDatabaseExportTab(context);
}
Widget _buildDocumentExportTab(BuildContext context) {
return Column(
children: [
const VSpace(10),
@ -44,16 +55,29 @@ class ExportTab extends StatelessWidget {
);
}
Widget _buildDatabaseExportTab(BuildContext context) {
return Column(
children: [
const VSpace(10),
_ExportButton(
title: LocaleKeys.shareAction_csv.tr(),
svg: FlowySvgs.database_layout_m,
onTap: () => _exportCSV(context),
),
],
);
}
Future<void> _exportHTML(BuildContext context) async {
final viewName = context.read<DocumentShareBloc>().state.viewName;
final viewName = context.read<ShareBloc>().state.viewName;
final exportPath = await getIt<FilePickerService>().saveFile(
dialogTitle: '',
fileName: '${viewName.toFileName()}.html',
);
if (context.mounted && exportPath != null) {
context.read<DocumentShareBloc>().add(
DocumentShareEvent.share(
DocumentShareType.html,
context.read<ShareBloc>().add(
ShareEvent.share(
ShareType.html,
exportPath,
),
);
@ -61,15 +85,31 @@ class ExportTab extends StatelessWidget {
}
Future<void> _exportMarkdown(BuildContext context) async {
final viewName = context.read<DocumentShareBloc>().state.viewName;
final viewName = context.read<ShareBloc>().state.viewName;
final exportPath = await getIt<FilePickerService>().saveFile(
dialogTitle: '',
fileName: '${viewName.toFileName()}.md',
);
if (context.mounted && exportPath != null) {
context.read<DocumentShareBloc>().add(
DocumentShareEvent.share(
DocumentShareType.markdown,
context.read<ShareBloc>().add(
ShareEvent.share(
ShareType.markdown,
exportPath,
),
);
}
}
Future<void> _exportCSV(BuildContext context) async {
final viewName = context.read<ShareBloc>().state.viewName;
final exportPath = await getIt<FilePickerService>().saveFile(
dialogTitle: '',
fileName: '${viewName.toFileName()}.csv',
);
if (context.mounted && exportPath != null) {
context.read<ShareBloc>().add(
ShareEvent.share(
ShareType.csv,
exportPath,
),
);
@ -77,8 +117,7 @@ class ExportTab extends StatelessWidget {
}
Future<void> _exportToClipboard(BuildContext context) async {
final documentExporter =
DocumentExporter(context.read<DocumentShareBloc>().view);
final documentExporter = DocumentExporter(context.read<ShareBloc>().view);
final result = await documentExporter.export(DocumentExportType.markdown);
result.fold(
(markdown) {

View File

@ -0,0 +1,559 @@
import 'package:appflowy/core/helpers/url_launcher.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/application/tab_bar_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/plugins/shared/share/publish_color_extension.dart';
import 'package:appflowy/plugins/shared/share/publish_name_generator.dart';
import 'package:appflowy/plugins/shared/share/share_bloc.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flowy_infra_ui/widget/flowy_tooltip.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<ShareBloc, ShareState>(
listener: (context, state) {
_showToast(context, state);
},
builder: (context, state) {
if (state.isPublished) {
return _PublishedWidget(
url: state.url,
onVisitSite: (url) => afLaunchUrlString(url),
onUnPublish: () {
context.read<ShareBloc>().add(const ShareEvent.unPublish());
},
);
} else {
return _PublishWidget(
onPublish: (selectedViews) async {
final id = context.read<ShareBloc>().view.id;
final publishName = await generatePublishName(
id,
state.viewName,
);
if (selectedViews.isNotEmpty) {
Log.info(
'Publishing views: ${selectedViews.map((e) => e.name)}',
);
}
if (context.mounted) {
context.read<ShareBloc>().add(
ShareEvent.publish(
'',
publishName,
selectedViews.map((e) => e.id).toList(),
),
);
}
},
);
}
},
);
}
void _showToast(BuildContext context, ShareState state) {
if (state.publishResult != null) {
state.publishResult!.fold(
(value) => showToastNotification(
context,
message: LocaleKeys.publish_publishSuccessfully.tr(),
),
(error) => showToastNotification(
context,
message: '${LocaleKeys.publish_publishFailed.tr()}: ${error.code}',
),
);
} else if (state.unpublishResult != null) {
state.unpublishResult!.fold(
(value) => showToastNotification(
context,
message: LocaleKeys.publish_unpublishSuccessfully.tr(),
),
(error) => showToastNotification(
context,
message: LocaleKeys.publish_unpublishFailed.tr(),
description: error.msg,
),
);
}
}
}
class _PublishedWidget extends StatefulWidget {
const _PublishedWidget({
required this.url,
required this.onVisitSite,
required this.onUnPublish,
});
final String url;
final void Function(String url) 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) {
getIt<ClipboardService>().setData(
ClipboardServiceData(plainText: url),
);
showToastNotification(
context,
message: LocaleKeys.grid_url_copy.tr(),
);
},
onSubmitted: (url) {},
),
const VSpace(16),
Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildUnpublishButton(),
const Spacer(),
_buildVisitSiteButton(),
],
),
],
);
}
Widget _buildUnpublishButton() {
return SizedBox(
width: 184,
height: 36,
child: FlowyButton(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
border: Border.all(color: ShareMenuColors.borderColor(context)),
),
radius: BorderRadius.circular(10),
text: FlowyText.regular(
LocaleKeys.shareAction_unPublish.tr(),
textAlign: TextAlign.center,
),
onTap: widget.onUnPublish,
),
);
}
Widget _buildVisitSiteButton() {
return RoundedTextButton(
width: 184,
height: 36,
onPressed: () => widget.onVisitSite(controller.text),
title: LocaleKeys.shareAction_visitSite.tr(),
borderRadius: const BorderRadius.all(Radius.circular(10)),
fillColor: Theme.of(context).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onPrimary,
);
}
}
class _PublishWidget extends StatefulWidget {
const _PublishWidget({
required this.onPublish,
});
final void Function(List<ViewPB> selectedViews) onPublish;
@override
State<_PublishWidget> createState() => _PublishWidgetState();
}
class _PublishWidgetState extends State<_PublishWidget> {
List<ViewPB> _selectedViews = [];
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const VSpace(16),
const _PublishTabHeader(),
const VSpace(16),
// if current view is a database, show the database selector
if (context.read<ShareBloc>().view.layout.isDatabaseView) ...[
_PublishDatabaseSelector(
view: context.read<ShareBloc>().view,
onSelected: (selectedDatabases) {
_selectedViews = selectedDatabases;
},
),
const VSpace(16),
],
_PublishButton(
onPublish: () {
if (context.read<ShareBloc>().view.layout.isDatabaseView) {
// check if any database is selected
if (_selectedViews.isEmpty) {
showToastNotification(
context,
message: LocaleKeys.publish_noDatabaseSelected.tr(),
);
return;
}
}
widget.onPublish(_selectedViews);
},
),
],
);
}
}
class _PublishButton extends StatelessWidget {
const _PublishButton({
required this.onPublish,
});
final VoidCallback onPublish;
@override
Widget build(BuildContext context) {
return 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(
readOnly: true,
autoFocus: false,
controller: controller,
enableBorderColor: ShareMenuColors.borderColor(context),
suffixIcon: _buildCopyLinkIcon(context),
),
);
}
Widget _buildCopyLinkIcon(BuildContext context) {
return FlowyHover(
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,
),
),
),
);
}
}
// used to select which database view should be published
class _PublishDatabaseSelector extends StatefulWidget {
const _PublishDatabaseSelector({
required this.view,
required this.onSelected,
});
final ViewPB view;
final void Function(List<ViewPB> selectedDatabases) onSelected;
@override
State<_PublishDatabaseSelector> createState() =>
_PublishDatabaseSelectorState();
}
class _PublishDatabaseSelectorState extends State<_PublishDatabaseSelector> {
final PropertyValueNotifier<List<(ViewPB, bool)>> _databaseStatus =
PropertyValueNotifier<List<(ViewPB, bool)>>([]);
late final _borderColor = Theme.of(context).hintColor.withOpacity(0.3);
@override
void initState() {
super.initState();
_databaseStatus.addListener(() {
final selectedDatabases =
_databaseStatus.value.where((e) => e.$2).map((e) => e.$1).toList();
widget.onSelected(selectedDatabases);
});
_databaseStatus.value = context
.read<DatabaseTabBarBloc>()
.state
.tabBars
.map((e) => (e.view, true))
.toList();
}
@override
void dispose() {
_databaseStatus.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocBuilder<DatabaseTabBarBloc, DatabaseTabBarState>(
builder: (context, state) {
return Container(
clipBehavior: Clip.antiAlias,
decoration: ShapeDecoration(
shape: RoundedRectangleBorder(
side: BorderSide(color: _borderColor),
borderRadius: BorderRadius.circular(8),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const VSpace(10),
_buildSelectedDatabaseCount(context),
const VSpace(10),
_buildDivider(context),
const VSpace(10),
...state.tabBars.map(
(e) => _buildDatabaseSelector(context, e),
),
],
),
);
},
);
}
Widget _buildDivider(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Divider(
color: _borderColor,
thickness: 1,
height: 1,
),
);
}
Widget _buildSelectedDatabaseCount(BuildContext context) {
return ValueListenableBuilder(
valueListenable: _databaseStatus,
builder: (context, selectedDatabases, child) {
final count = selectedDatabases.where((e) => e.$2).length;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: FlowyText(
LocaleKeys.publish_database.plural(count).tr(),
color: Theme.of(context).hintColor,
fontSize: 13,
),
);
},
);
}
Widget _buildDatabaseSelector(BuildContext context, DatabaseTabBar tabBar) {
final isPrimaryDatabase = tabBar.view.id == widget.view.id;
return ValueListenableBuilder(
valueListenable: _databaseStatus,
builder: (context, selectedDatabases, child) {
final isSelected = selectedDatabases.any(
(e) => e.$1.id == tabBar.view.id && e.$2,
);
return _DatabaseSelectorItem(
tabBar: tabBar,
isSelected: isSelected,
isPrimaryDatabase: isPrimaryDatabase,
onTap: () {
// unable to deselect the primary database
if (isPrimaryDatabase) {
showToastNotification(
context,
message:
LocaleKeys.publish_unableToDeselectPrimaryDatabase.tr(),
);
return;
}
// toggle the selection status
_databaseStatus.value = _databaseStatus.value
.map(
(e) =>
e.$1.id == tabBar.view.id ? (e.$1, !e.$2) : (e.$1, e.$2),
)
.toList();
},
);
},
);
}
}
class _DatabaseSelectorItem extends StatelessWidget {
const _DatabaseSelectorItem({
required this.tabBar,
required this.isSelected,
required this.onTap,
required this.isPrimaryDatabase,
});
final DatabaseTabBar tabBar;
final bool isSelected;
final VoidCallback onTap;
final bool isPrimaryDatabase;
@override
Widget build(BuildContext context) {
Widget child = _buildItem(context);
if (!isPrimaryDatabase) {
child = FlowyHover(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: child,
),
);
} else {
child = FlowyTooltip(
message: LocaleKeys.publish_mustSelectPrimaryDatabase.tr(),
child: MouseRegion(
cursor: SystemMouseCursors.forbidden,
child: child,
),
);
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
child: child,
);
}
Widget _buildItem(BuildContext context) {
final svg = isPrimaryDatabase
? FlowySvgs.unable_select_s
: isSelected
? FlowySvgs.check_filled_s
: FlowySvgs.uncheck_s;
final blendMode = isPrimaryDatabase ? BlendMode.srcIn : null;
return Container(
height: 30,
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
children: [
FlowySvg(
svg,
blendMode: blendMode,
size: const Size.square(18),
),
const HSpace(9.0),
FlowySvg(
tabBar.view.layout.icon,
size: const Size.square(16),
),
const HSpace(6.0),
FlowyText.regular(
tabBar.view.name,
fontSize: 14,
overflow: TextOverflow.ellipsis,
),
],
),
);
}
}

View File

@ -2,10 +2,10 @@ import 'dart:io';
import 'package:appflowy/user/application/user_service.dart';
import 'package:appflowy/workspace/application/export/document_exporter.dart';
import 'package:appflowy/workspace/application/settings/share/export_service.dart';
import 'package:appflowy/workspace/application/view/view_listener.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_backend/protobuf/flowy-user/protobuf.dart';
@ -13,31 +13,31 @@ import 'package:appflowy_result/appflowy_result.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'document_share_bloc.freezed.dart';
part 'share_bloc.freezed.dart';
const _url = 'https://appflowy.com';
class DocumentShareBloc extends Bloc<DocumentShareEvent, DocumentShareState> {
DocumentShareBloc({
class ShareBloc extends Bloc<ShareEvent, ShareState> {
ShareBloc({
required this.view,
}) : super(DocumentShareState.initial()) {
on<DocumentShareEvent>((event, emit) async {
}) : super(ShareState.initial()) {
on<ShareEvent>((event, emit) async {
await event.when(
initial: () async {
viewListener = ViewListener(viewId: view.id)
..start(
onViewUpdated: (value) {
add(DocumentShareEvent.updateViewName(value.name));
add(ShareEvent.updateViewName(value.name));
},
onViewMoveToTrash: (p0) {
add(const DocumentShareEvent.setPublishStatus(false));
add(const ShareEvent.setPublishStatus(false));
},
);
add(const DocumentShareEvent.updatePublishStatus());
add(const ShareEvent.updatePublishStatus());
},
share: (type, path) async {
if (DocumentShareType.unimplemented.contains(type)) {
if (ShareType.unimplemented.contains(type)) {
Log.error('DocumentShareType $type is not implemented');
return;
}
@ -53,7 +53,7 @@ class DocumentShareBloc extends Bloc<DocumentShareEvent, DocumentShareState> {
),
);
},
publish: (nameSpace, publishName) async {
publish: (nameSpace, publishName, selectedViewIds) async {
// set space name
try {
final result =
@ -62,6 +62,7 @@ class DocumentShareBloc extends Bloc<DocumentShareEvent, DocumentShareState> {
await ViewBackendService.publish(
view,
name: publishName,
selectedViewIds: selectedViewIds,
).getOrThrow();
emit(
@ -72,6 +73,8 @@ class DocumentShareBloc extends Bloc<DocumentShareEvent, DocumentShareState> {
url: '$_url/${result.namespace}/$publishName',
),
);
Log.info('publish success: ${result.namespace}/$publishName');
} catch (e) {
Log.error('publish error: $e');
@ -155,7 +158,7 @@ class DocumentShareBloc extends Bloc<DocumentShareEvent, DocumentShareState> {
final ViewPB view;
late final ViewListener viewListener;
late final exporter = DocumentExporter(view);
late final documentExporter = DocumentExporter(view);
@override
Future<void> close() async {
@ -163,19 +166,31 @@ class DocumentShareBloc extends Bloc<DocumentShareEvent, DocumentShareState> {
return super.close();
}
Future<FlowyResult<ExportDataPB, FlowyError>> _export(
DocumentShareType type,
Future<FlowyResult<ShareType, FlowyError>> _export(
ShareType type,
String? path,
) async {
final result = await exporter.export(type.exportType);
final FlowyResult<String, FlowyError> result;
if (type == ShareType.csv) {
final exportResult = await BackendExportService.exportDatabaseAsCSV(
view.id,
);
result = exportResult.fold(
(s) => FlowyResult.success(s.data),
(f) => FlowyResult.failure(f),
);
} else {
result = await documentExporter.export(type.documentExportType);
}
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));
case ShareType.markdown:
case ShareType.html:
case ShareType.csv:
File(path).writeAsStringSync(s);
return FlowyResult.success(type);
default:
break;
}
@ -185,77 +200,69 @@ class DocumentShareBloc extends Bloc<DocumentShareEvent, DocumentShareState> {
(f) => FlowyResult.failure(f),
);
}
ExportDataPB _saveMarkdownToPath(String markdown, String path) {
File(path).writeAsStringSync(markdown);
return ExportDataPB()
..data = markdown
..exportType = ExportType.Markdown;
}
ExportDataPB _saveHTMLToPath(String html, String path) {
File(path).writeAsStringSync(html);
return ExportDataPB()
..data = html
..exportType = ExportType.HTML;
}
}
enum DocumentShareType {
enum ShareType {
// available in document
markdown,
html,
text,
link;
link,
static List<DocumentShareType> get unimplemented => [text, link];
// only available in database
csv;
DocumentExportType get exportType {
static List<ShareType> get unimplemented => [link];
DocumentExportType get documentExportType {
switch (this) {
case DocumentShareType.markdown:
case ShareType.markdown:
return DocumentExportType.markdown;
case DocumentShareType.html:
case ShareType.html:
return DocumentExportType.html;
case DocumentShareType.text:
case ShareType.text:
return DocumentExportType.text;
case DocumentShareType.link:
case ShareType.csv:
throw UnsupportedError('DocumentShareType.csv is not supported');
case ShareType.link:
throw UnsupportedError('DocumentShareType.link is not supported');
}
}
}
@freezed
class DocumentShareEvent with _$DocumentShareEvent {
const factory DocumentShareEvent.initial() = _Initial;
const factory DocumentShareEvent.share(
DocumentShareType type,
class ShareEvent with _$ShareEvent {
const factory ShareEvent.initial() = _Initial;
const factory ShareEvent.share(
ShareType type,
String? path,
) = _Share;
const factory DocumentShareEvent.publish(
const factory ShareEvent.publish(
String nameSpace,
String pageId,
List<String> selectedViewIds,
) = _Publish;
const factory DocumentShareEvent.unPublish() = _UnPublish;
const factory DocumentShareEvent.updateViewName(String name) =
_UpdateViewName;
const factory DocumentShareEvent.updatePublishStatus() = _UpdatePublishStatus;
const factory DocumentShareEvent.setPublishStatus(bool isPublished) =
const factory ShareEvent.unPublish() = _UnPublish;
const factory ShareEvent.updateViewName(String name) = _UpdateViewName;
const factory ShareEvent.updatePublishStatus() = _UpdatePublishStatus;
const factory ShareEvent.setPublishStatus(bool isPublished) =
_SetPublishStatus;
}
@freezed
class DocumentShareState with _$DocumentShareState {
const factory DocumentShareState({
class ShareState with _$ShareState {
const factory ShareState({
required bool isPublished,
required bool isLoading,
required String url,
required String viewName,
required bool enablePublish,
FlowyResult<ExportDataPB, FlowyError>? exportResult,
FlowyResult<ShareType, FlowyError>? exportResult,
FlowyResult<void, FlowyError>? publishResult,
FlowyResult<void, FlowyError>? unpublishResult,
}) = _DocumentShareState;
}) = _ShareState;
factory DocumentShareState.initial() => const DocumentShareState(
factory ShareState.initial() => const ShareState(
isLoading: false,
isPublished: false,
enablePublish: true,

View File

@ -0,0 +1,82 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/application/tab_bar_bloc.dart';
import 'package:appflowy/plugins/shared/share/_shared.dart';
import 'package:appflowy/plugins/shared/share/share_bloc.dart';
import 'package:appflowy/plugins/shared/share/share_menu.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class ShareButton extends StatelessWidget {
const ShareButton({
super.key,
required this.view,
});
final ViewPB view;
@override
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
BlocProvider(
create: (context) =>
getIt<ShareBloc>(param1: view)..add(const ShareEvent.initial()),
),
if (view.layout.isDatabaseView)
BlocProvider(
create: (context) => DatabaseTabBarBloc(view: view)
..add(const DatabaseTabBarEvent.initial()),
),
],
child: BlocListener<ShareBloc, ShareState>(
listener: (context, state) {
if (!state.isLoading && state.exportResult != null) {
state.exportResult!.fold(
(data) => _handleExportSuccess(context, data),
(error) => _handleExportError(context, error),
);
}
},
child: BlocBuilder<ShareBloc, ShareState>(
builder: (context, state) {
final tabs = [
if (state.enablePublish) ShareMenuTab.publish,
ShareMenuTab.exportAs,
];
return ShareMenuButton(tabs: tabs);
},
),
),
);
}
void _handleExportSuccess(BuildContext context, ShareType shareType) {
switch (shareType) {
case ShareType.markdown:
case ShareType.html:
case ShareType.csv:
showToastNotification(
context,
message: LocaleKeys.settings_files_exportFileSuccess.tr(),
);
break;
default:
break;
}
}
void _handleExportError(BuildContext context, FlowyError error) {
showToastNotification(
context,
message:
'${LocaleKeys.settings_files_exportFileFail.tr()}: ${error.code}',
);
}
}

View File

@ -1,8 +1,8 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/home/tab/_round_underline_tab_indicator.dart';
import 'package:appflowy/plugins/document/application/document_share_bloc.dart';
import 'package:appflowy/plugins/document/presentation/share/export_tab.dart';
import 'package:appflowy/plugins/shared/share/export_tab.dart';
import 'package:appflowy/plugins/shared/share/share_bloc.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
@ -165,7 +165,7 @@ class _SegmentState extends State<_Segment> {
);
if (widget.tab == ShareMenuTab.publish) {
final isPublished = context.watch<DocumentShareBloc>().state.isPublished;
final isPublished = context.watch<ShareBloc>().state.isPublished;
// show checkmark icon if published
if (isPublished) {
child = Row(

View File

@ -1,18 +1,18 @@
export "./src/sizes.dart";
export "./src/trash_cell.dart";
export "./src/trash_header.dart";
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/startup/plugin/plugin.dart';
import 'package:appflowy/workspace/presentation/home/home_stack.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'trash_page.dart';
export "./src/sizes.dart";
export "./src/trash_cell.dart";
export "./src/trash_header.dart";
class TrashPluginBuilder extends PluginBuilder {
@override
Plugin build(dynamic data) {
@ -66,6 +66,7 @@ class TrashPluginDisplay extends PluginWidgetBuilder {
Widget buildWidget({
required PluginContext context,
required bool shrinkWrap,
Map<String, dynamic>? data,
}) =>
const TrashPage(
key: ValueKey('TrashPage'),

View File

@ -182,8 +182,8 @@ void _resolveHomeDeps(GetIt getIt) {
);
// share
getIt.registerFactoryParam<DocumentShareBloc, ViewPB, void>(
(view, _) => DocumentShareBloc(view: view),
getIt.registerFactoryParam<ShareBloc, ViewPB, void>(
(view, _) => ShareBloc(view: view),
);
getIt.registerSingleton<ActionNavigationBloc>(ActionNavigationBloc());

View File

@ -1,13 +1,12 @@
library flowy_plugin;
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
import 'package:flutter/widgets.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/startup/plugin/plugin.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/presentation/home/home_stack.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
import 'package:flutter/widgets.dart';
export "./src/sandbox.dart";
@ -76,6 +75,7 @@ abstract class PluginWidgetBuilder with NavigationItem {
Widget buildWidget({
required PluginContext context,
required bool shrinkWrap,
Map<String, dynamic>? data,
});
}

View File

@ -106,6 +106,8 @@ class SidebarSectionsBloc
case ViewSectionPB.Public:
emit(
state.copyWith(
containsSpace: state.containsSpace ||
sectionViews.views.any((view) => view.isSpace),
section: state.section.copyWith(
publicViews: sectionViews.views,
),
@ -114,6 +116,8 @@ class SidebarSectionsBloc
case ViewSectionPB.Private:
emit(
state.copyWith(
containsSpace: state.containsSpace ||
sectionViews.views.any((view) => view.isSpace),
section: state.section.copyWith(
privateViews: sectionViews.views,
),

View File

@ -231,7 +231,7 @@ extension ViewLayoutExtension on ViewLayoutPB {
FlowySvgData get icon => switch (this) {
ViewLayoutPB.Grid => FlowySvgs.grid_s,
ViewLayoutPB.Board => FlowySvgs.board_s,
ViewLayoutPB.Calendar => FlowySvgs.date_s,
ViewLayoutPB.Calendar => FlowySvgs.calendar_s,
ViewLayoutPB.Document => FlowySvgs.document_s,
ViewLayoutPB.Chat => FlowySvgs.chat_ai_page_s,
_ => throw Exception('Unknown layout type'),

View File

@ -302,12 +302,18 @@ class ViewBackendService {
static Future<FlowyResult<void, FlowyError>> publish(
ViewPB view, {
String? name,
List<String>? selectedViewIds,
}) async {
final payload = PublishViewParamsPB()..viewId = view.id;
if (name != null) {
payload.publishName = name;
}
if (selectedViewIds != null && selectedViewIds.isNotEmpty) {
payload.selectedViewIds = RepeatedViewIdPB(items: selectedViewIds);
}
return FolderEventPublishView(payload).send();
}

View File

@ -342,7 +342,14 @@ class _SidebarState extends State<_Sidebar> {
child: const Divider(height: 0.5, color: Color(0x141F2329)),
),
const VSpace(8),
_renderUpgradeSpaceButton(menuHorizontalInset),
Column(
children: [
// ai pay button
_renderUpgradeSpaceButton(menuHorizontalInset),
],
),
const VSpace(8),
Padding(
padding: menuHorizontalInset +

View File

@ -1,5 +1,3 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_configuration.dart';
@ -14,6 +12,7 @@ import 'package:fixnum/fixnum.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class NotificationItem extends StatefulWidget {
@ -239,19 +238,22 @@ class _NotificationContent extends StatelessWidget {
padding: EdgeInsets.zero,
);
return Transform.scale(
scale: .9,
alignment: Alignment.centerLeft,
child: AppFlowyEditor(
editorState: editorState,
editorStyle: styleCustomizer.style(),
editable: false,
shrinkWrap: true,
blockComponentBuilders: getEditorBuilderMap(
context: context,
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 200),
child: Transform.scale(
scale: .9,
alignment: Alignment.centerLeft,
child: AppFlowyEditor(
editorState: editorState,
styleCustomizer: styleCustomizer,
editorStyle: styleCustomizer.style(),
editable: false,
shrinkWrap: true,
blockComponentBuilders: getEditorBuilderMap(
context: context,
editorState: editorState,
styleCustomizer: styleCustomizer,
editable: false,
),
),
),
);

View File

@ -298,7 +298,10 @@ void showToastNotification(
context: context,
type: type,
style: ToastificationStyle.flat,
title: FlowyText(message),
title: FlowyText(
message,
maxLines: 3,
),
description: description != null
? FlowyText.regular(
description,

View File

@ -14,6 +14,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:package_info_plus/package_info_plus.dart';
@ -209,7 +210,7 @@ class FlowyVersionDescription extends CustomActionCell {
GestureDetector(
behavior: HitTestBehavior.opaque,
onDoubleTap: () {
if (Env.internalBuild != '1') {
if (Env.internalBuild != '1' && !kDebugMode) {
return;
}
enableDocumentInternalLog = !enableDocumentInternalLog;

View File

@ -34,4 +34,8 @@ extension FlowyAsyncResultExtension<S, F extends Object>
FlowyAsyncResult<S, F> onFailure(void Function(F failure) onFailure) {
return then((result) => result..onFailure(onFailure));
}
FlowyAsyncResult<S, F> onSuccess(void Function(S success) onSuccess) {
return then((result) => result..onSuccess(onSuccess));
}
}

View File

@ -0,0 +1,4 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2.25" y="2.25" width="13.5" height="13.5" rx="4.5" fill="#171717" fill-opacity="0.2"/>
<path d="M6.75 9L8.56731 10.6875L11.8125 7.3125" stroke="white" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 333 B

View File

@ -2087,7 +2087,17 @@
"noAccessToVisit": "No access to this page...",
"createWithAppFlowy": "Create a website with AppFlowy",
"fastWithAI": "Fast and easy with AI.",
"tryItNow": "Try it now"
"tryItNow": "Try it now",
"onlyGridViewCanBePublished": "Only Grid view can be published",
"database": {
"zero": "Publish {} selected view",
"one": "Publish {} selected views",
"many": "Publish {} selected views",
"other":"Publish {} selected views"
},
"mustSelectPrimaryDatabase": "The primary database must be selected",
"noDatabaseSelected": "No database selected, please select at least one database.",
"unableToDeselectPrimaryDatabase": "Unable to deselect primary database"
},
"web": {
"continue": "Continue",

View File

@ -1,4 +1,4 @@
use collab::entity::EncodedCollab;
use flowy_folder::view_operation::EncodedCollabWrapper;
use std::sync::Arc;
use collab_folder::{FolderData, View};
@ -6,7 +6,7 @@ use flowy_folder::entities::icon::UpdateViewIconPayloadPB;
use flowy_folder::event_map::FolderEvent;
use flowy_folder::event_map::FolderEvent::*;
use flowy_folder::{entities::*, ViewLayout};
use flowy_folder_pub::entities::PublishViewPayload;
use flowy_folder_pub::entities::PublishPayload;
use flowy_search::services::manager::{SearchHandler, SearchType};
use flowy_user::entities::{
AcceptWorkspaceInvitationPB, QueryWorkspacePB, RemoveWorkspaceMemberPB,
@ -177,8 +177,8 @@ impl EventIntegrationTest {
pub async fn get_publish_payload(
&self,
view_id: &str,
include_children: Option<bool>,
) -> Vec<PublishViewPayload> {
include_children: bool,
) -> Vec<PublishPayload> {
let manager = self.folder_manager.clone();
let payload = manager
.get_batch_publish_payload(view_id, None, include_children)
@ -191,11 +191,19 @@ impl EventIntegrationTest {
payload.unwrap()
}
pub async fn encoded_collab_v1(&self, view_id: &str, layout: ViewLayout) -> EncodedCollab {
pub async fn get_encoded_collab_v1_from_disk(
&self,
view_id: &str,
layout: ViewLayout,
) -> EncodedCollabWrapper {
let manager = self.folder_manager.clone();
let user = manager.get_user().clone();
let handlers = manager.get_operation_handlers();
let handler = handlers.get(&layout).unwrap();
handler.encoded_collab_v1(view_id, layout).await.unwrap()
handler
.get_encoded_collab_v1_from_disk(user, view_id)
.await
.unwrap()
}
pub async fn get_all_workspace_views(&self) -> Vec<ViewPB> {
@ -207,6 +215,16 @@ impl EventIntegrationTest {
.items
}
// get all the views in the current workspace, including the views in the trash and the orphan views
pub async fn get_all_views(&self) -> Vec<ViewPB> {
EventBuilder::new(self.clone())
.event(FolderEvent::GetAllViews)
.async_send()
.await
.parse::<RepeatedViewPB>()
.items
}
pub async fn get_trash(&self) -> RepeatedTrashPB {
EventBuilder::new(self.clone())
.event(FolderEvent::ListTrashItems)
@ -252,12 +270,23 @@ impl EventIntegrationTest {
}
pub async fn create_view(&self, parent_id: &str, name: String) -> ViewPB {
self
.create_view_with_layout(parent_id, name, Default::default())
.await
}
pub async fn create_view_with_layout(
&self,
parent_id: &str,
name: String,
layout: ViewLayoutPB,
) -> ViewPB {
let payload = CreateViewPayloadPB {
parent_view_id: parent_id.to_string(),
name,
desc: "".to_string(),
thumbnail: None,
layout: Default::default(),
layout,
initial_data: vec![],
meta: Default::default(),
set_as_current: false,

View File

@ -3,4 +3,6 @@ mod import_test;
mod script;
mod subscription_test;
mod test;
mod view_publish_test;
mod publish_database_test;
mod publish_document_test;

View File

@ -0,0 +1,156 @@
use std::collections::HashMap;
use collab_folder::ViewLayout;
use event_integration_test::EventIntegrationTest;
use flowy_folder::entities::{
ImportPayloadPB, ImportTypePB, ImportValuePayloadPB, ViewLayoutPB, ViewPB,
};
use flowy_folder::view_operation::EncodedCollabWrapper;
use crate::util::unzip;
#[tokio::test]
async fn publish_single_database_test() {
let test = EventIntegrationTest::new_anon().await;
test.sign_up_as_anon().await;
// import a csv file and try to get its publish collab
let grid = import_csv("publish_grid_primary.csv", &test).await;
let grid_encoded_collab = test
.get_encoded_collab_v1_from_disk(&grid.id, ViewLayout::Grid)
.await;
match grid_encoded_collab {
EncodedCollabWrapper::Database(encoded_collab) => {
// the len of row collabs should be the same as the number of rows in the csv file
let rows_len = encoded_collab.database_row_encoded_collabs.len();
assert_eq!(rows_len, 18);
},
_ => panic!("Expected database collab"),
}
}
#[tokio::test]
async fn publish_databases_from_existing_workspace() {
let test = EventIntegrationTest::new_anon().await;
test.sign_up_as_anon().await;
// import a workspace
// there's a sample screenshot of the workspace in the asset folder,
// unzip it and check the views if needed
let _ = import_workspace("064_database_publish", &test).await;
let publish_database_set = test.get_all_views().await;
let publish_grid_set = publish_database_set
.iter()
// there're 8 built-in grids in the workspace with the name starting with "publish grid"
.filter(|view| view.layout == ViewLayoutPB::Grid && view.name.starts_with("publish grid"))
.collect::<Vec<_>>();
let publish_calendar_set = publish_database_set
.iter()
// there's 1 built-in calender in the workspace with the name starting with "publish calendar"
.filter(|view| view.layout == ViewLayoutPB::Calendar && view.name.starts_with("publish calendar"))
.collect::<Vec<_>>();
let publish_board_set = publish_database_set
.iter()
// there's 1 built-in board in the workspace with the name starting with "publish board"
.filter(|view| view.layout == ViewLayoutPB::Board && view.name.starts_with("publish board"))
.collect::<Vec<_>>();
let mut expectations: HashMap<&str, usize> = HashMap::new();
// grid
// 5 rows
expectations.insert("publish grid (deprecated)", 5);
// the following 7 grids are the same, just with different filters or sorting or layout
// to check if the collab is correctly generated
// 18 rows
expectations.insert("publish grid", 18);
// 18 rows
expectations.insert("publish grid (with board)", 18);
// 18 rows
expectations.insert("publish grid (with calendar)", 18);
// 18 rows
expectations.insert("publish grid (with grid)", 18);
// 18 rows
expectations.insert("publish grid (filtered)", 18);
// 18 rows
expectations.insert("publish grid (sorted)", 18);
// calendar
expectations.insert("publish calendar", 2);
// board
expectations.insert("publish board", 15);
test_publish_encode_collab_result(&test, publish_grid_set, expectations.clone()).await;
test_publish_encode_collab_result(&test, publish_calendar_set, expectations.clone()).await;
test_publish_encode_collab_result(&test, publish_board_set, expectations.clone()).await;
}
async fn test_publish_encode_collab_result(
test: &EventIntegrationTest,
views: Vec<&ViewPB>,
expectations: HashMap<&str, usize>,
) {
for view in views {
let id = view.id.clone();
let layout = view.layout.clone();
test.open_database(&id).await;
let encoded_collab = test
.get_encoded_collab_v1_from_disk(&id, layout.into())
.await;
match encoded_collab {
EncodedCollabWrapper::Database(encoded_collab) => {
if let Some(rows_len) = expectations.get(&view.name.as_str()) {
assert_eq!(encoded_collab.database_row_encoded_collabs.len(), *rows_len);
}
},
_ => panic!("Expected database collab"),
}
}
}
async fn import_workspace(file_name: &str, test: &EventIntegrationTest) -> Vec<ViewPB> {
let (cleaner, file_path) = unzip("./tests/asset", file_name).unwrap();
test
.import_appflowy_data(file_path.to_str().unwrap().to_string(), None)
.await
.unwrap();
let views = test.get_all_workspace_views().await;
drop(cleaner);
views
}
async fn import_csv(file_name: &str, test: &EventIntegrationTest) -> ViewPB {
let (cleaner, file_path) = unzip("./tests/asset", file_name).unwrap();
let csv_string = std::fs::read_to_string(file_path).unwrap();
let workspace_id = test.get_current_workspace().await.id;
let import_data = gen_import_data(file_name.to_string(), csv_string, workspace_id);
let views = test.import_data(import_data).await;
drop(cleaner);
views[0].clone()
}
fn gen_import_data(file_name: String, csv_string: String, workspace_id: String) -> ImportPayloadPB {
ImportPayloadPB {
parent_view_id: workspace_id.clone(),
sync_after_create: false,
values: vec![ImportValuePayloadPB {
name: file_name,
data: Some(csv_string.as_bytes().to_vec()),
file_path: None,
view_layout: ViewLayoutPB::Grid,
import_type: ImportTypePB::CSV,
}],
}
}

View File

@ -2,18 +2,19 @@ use collab_folder::ViewLayout;
use event_integration_test::EventIntegrationTest;
use flowy_folder::entities::{ViewLayoutPB, ViewPB};
use flowy_folder::publish_util::generate_publish_name;
use flowy_folder::view_operation::EncodedCollabWrapper;
use flowy_folder_pub::entities::{
PublishViewInfo, PublishViewMeta, PublishViewMetaData, PublishViewPayload,
PublishDocumentPayload, PublishPayload, PublishViewInfo, PublishViewMeta, PublishViewMetaData,
};
async fn mock_single_document_view_publish_payload(
test: &EventIntegrationTest,
view: &ViewPB,
publish_name: String,
) -> Vec<PublishViewPayload> {
) -> Vec<PublishPayload> {
let view_id = &view.id;
let layout: ViewLayout = view.layout.clone().into();
let view_encoded_collab = test.encoded_collab_v1(view_id, layout).await;
let view_encoded_collab = test.get_encoded_collab_v1_from_disk(view_id, layout).await;
let publish_view_info = PublishViewInfo {
view_id: view_id.to_string(),
name: view.name.to_string(),
@ -27,7 +28,12 @@ async fn mock_single_document_view_publish_payload(
child_views: None,
};
vec![PublishViewPayload {
let data = match view_encoded_collab {
EncodedCollabWrapper::Document(doc) => doc.document_encoded_collab.doc_state.to_vec(),
_ => panic!("Expected document collab"),
};
vec![PublishPayload::Document(PublishDocumentPayload {
meta: PublishViewMeta {
metadata: PublishViewMetaData {
view: publish_view_info.clone(),
@ -37,18 +43,18 @@ async fn mock_single_document_view_publish_payload(
view_id: view_id.to_string(),
publish_name,
},
data: Vec::from(view_encoded_collab.doc_state),
}]
data,
})]
}
async fn mock_nested_document_view_publish_payload(
test: &EventIntegrationTest,
view: &ViewPB,
publish_name: String,
) -> Vec<PublishViewPayload> {
) -> Vec<PublishPayload> {
let view_id = &view.id;
let layout: ViewLayout = view.layout.clone().into();
let view_encoded_collab = test.encoded_collab_v1(view_id, layout).await;
let view_encoded_collab = test.get_encoded_collab_v1_from_disk(view_id, layout).await;
let publish_view_info = PublishViewInfo {
view_id: view_id.to_string(),
name: view.name.to_string(),
@ -65,7 +71,9 @@ async fn mock_nested_document_view_publish_payload(
let child_view_id = &view.child_views[0].id;
let child_view = test.get_view(child_view_id).await;
let child_layout: ViewLayout = child_view.layout.clone().into();
let child_view_encoded_collab = test.encoded_collab_v1(child_view_id, child_layout).await;
let child_view_encoded_collab = test
.get_encoded_collab_v1_from_disk(child_view_id, child_layout)
.await;
let child_publish_view_info = PublishViewInfo {
view_id: child_view_id.to_string(),
name: child_view.name.to_string(),
@ -80,8 +88,18 @@ async fn mock_nested_document_view_publish_payload(
};
let child_publish_name = generate_publish_name(&child_view.id, &child_view.name);
let data = match view_encoded_collab {
EncodedCollabWrapper::Document(doc) => doc.document_encoded_collab.doc_state.to_vec(),
_ => panic!("Expected document collab"),
};
let child_data = match child_view_encoded_collab {
EncodedCollabWrapper::Document(doc) => doc.document_encoded_collab.doc_state.to_vec(),
_ => panic!("Expected document collab"),
};
vec![
PublishViewPayload {
PublishPayload::Document(PublishDocumentPayload {
meta: PublishViewMeta {
metadata: PublishViewMetaData {
view: publish_view_info.clone(),
@ -91,9 +109,9 @@ async fn mock_nested_document_view_publish_payload(
view_id: view_id.to_string(),
publish_name,
},
data: Vec::from(view_encoded_collab.doc_state),
},
PublishViewPayload {
data,
}),
PublishPayload::Document(PublishDocumentPayload {
meta: PublishViewMeta {
metadata: PublishViewMetaData {
view: child_publish_view_info.clone(),
@ -103,8 +121,8 @@ async fn mock_nested_document_view_publish_payload(
view_id: child_view_id.to_string(),
publish_name: child_publish_name,
},
data: Vec::from(child_view_encoded_collab.doc_state),
},
data: child_data,
}),
]
}
@ -126,7 +144,7 @@ async fn single_document_get_publish_view_payload_test() {
let name = "Orphan View";
create_single_document(&test, view_id, name).await;
let view = test.get_view(view_id).await;
let payload = test.get_publish_payload(view_id, Some(true)).await;
let payload = test.get_publish_payload(view_id, true).await;
let expect_payload = mock_single_document_view_publish_payload(
&test,
@ -145,7 +163,7 @@ async fn nested_document_get_publish_view_payload_test() {
let view_id = "20240521";
create_nested_document(&test, view_id, name).await;
let view = test.get_view(view_id).await;
let payload = test.get_publish_payload(view_id, Some(true)).await;
let payload = test.get_publish_payload(view_id, true).await;
let expect_payload = mock_nested_document_view_publish_payload(
&test,
@ -165,7 +183,7 @@ async fn no_children_publish_view_payload_test() {
let view_id = "20240521";
create_nested_document(&test, view_id, name).await;
let view = test.get_view(view_id).await;
let payload = test.get_publish_payload(view_id, Some(false)).await;
let payload = test.get_publish_payload(view_id, false).await;
let data = mock_single_document_view_publish_payload(
&test,
@ -174,7 +192,10 @@ async fn no_children_publish_view_payload_test() {
)
.await
.iter()
.map(|p| p.data.clone())
.filter_map(|p| match p {
PublishPayload::Document(payload) => Some(payload.data.clone()),
_ => None,
})
.collect::<Vec<_>>();
let meta = mock_nested_document_view_publish_payload(
&test,
@ -183,10 +204,24 @@ async fn no_children_publish_view_payload_test() {
)
.await
.iter()
.map(|p| p.meta.clone())
.filter_map(|p| match p {
PublishPayload::Document(payload) => Some(payload.meta.clone()),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(payload.len(), 1);
assert_eq!(&payload[0].data, &data[0]);
assert_eq!(&payload[0].meta, &meta[0]);
let payload_data = match &payload[0] {
PublishPayload::Document(payload) => payload.data.clone(),
_ => panic!("Expected document payload"),
};
let payload_meta = match &payload[0] {
PublishPayload::Document(payload) => payload.meta.clone(),
_ => panic!("Expected document payload"),
};
assert_eq!(&payload_data, &data[0]);
assert_eq!(&payload_meta, &meta[0]);
}

View File

@ -1,5 +1,6 @@
use bytes::Bytes;
use collab_entity::EncodedCollab;
use collab_entity::{CollabType, EncodedCollab};
use collab_integrate::collab_builder::AppFlowyCollabBuilder;
use collab_integrate::CollabKVDB;
use flowy_chat::chat_manager::ChatManager;
@ -15,13 +16,15 @@ use flowy_folder::entities::{CreateViewParams, ViewLayoutPB};
use flowy_folder::manager::{FolderManager, FolderUser};
use flowy_folder::share::ImportType;
use flowy_folder::view_operation::{
FolderOperationHandler, FolderOperationHandlers, View, ViewData,
DatabaseEncodedCollab, DocumentEncodedCollab, EncodedCollabWrapper, FolderOperationHandler,
FolderOperationHandlers, View, ViewData,
};
use flowy_folder::ViewLayout;
use flowy_folder_pub::folder_builder::NestedViewBuilder;
use flowy_search::folder::indexer::FolderIndexManagerImpl;
use flowy_sqlite::kv::KVStorePreferences;
use flowy_user::services::authenticate_user::AuthenticateUser;
use flowy_user::services::data_import::{load_collab_by_object_id, load_collab_by_object_ids};
use lib_dispatch::prelude::ToBytes;
use lib_infra::future::FutureResult;
use std::collections::HashMap;
@ -31,6 +34,8 @@ use tokio::sync::RwLock;
use crate::integrate::server::ServerProvider;
use collab_plugins::local_storage::kv::KVTransactionDB;
pub struct FolderDepsResolver();
#[allow(clippy::too_many_arguments)]
impl FolderDepsResolver {
@ -198,18 +203,46 @@ impl FolderOperationHandler for DocumentFolderOperation {
})
}
fn encoded_collab_v1(
fn get_encoded_collab_v1_from_disk(
&self,
user: Arc<dyn FolderUser>,
view_id: &str,
layout: ViewLayout,
) -> FutureResult<EncodedCollab, FlowyError> {
debug_assert_eq!(layout, ViewLayout::Document);
let view_id = view_id.to_string();
let manager = self.0.clone();
FutureResult::new(async move {
let encoded_collab = manager.encode_collab(&view_id).await?;
) -> FutureResult<EncodedCollabWrapper, FlowyError> {
// get the collab_object_id for the document.
// the collab_object_id for the document is the view_id.
let oid = view_id.to_string();
Ok(encoded_collab)
FutureResult::new(async move {
let uid = user
.user_id()
.map_err(|e| e.with_context("unable to get the uid: {}"))?;
// get the collab db
let collab_db = user
.collab_db(uid)
.map_err(|e| e.with_context("unable to get the collab"))?;
let collab_db = collab_db.upgrade().ok_or_else(|| {
FlowyError::internal().with_context(
"The collab db has been dropped, indicating that the user has switched to a new account",
)
})?;
let collab_read_txn = collab_db.read_txn();
// read the collab from the db
let collab = load_collab_by_object_id(uid, &collab_read_txn, &oid).map_err(|e| {
FlowyError::internal().with_context(format!("load document collab failed: {}", e))
})?;
let encoded_collab = collab
// encode the collab and check the integrity of the collab
.encode_collab_v1(|collab| CollabType::Document.validate_require_data(collab))
.map_err(|e| {
FlowyError::internal().with_context(format!("encode document collab failed: {}", e))
})?;
Ok(EncodedCollabWrapper::Document(DocumentEncodedCollab {
document_encoded_collab: encoded_collab,
}))
})
}
@ -300,13 +333,86 @@ impl FolderOperationHandler for DatabaseFolderOperation {
})
}
fn encoded_collab_v1(
fn get_encoded_collab_v1_from_disk(
&self,
_view_id: &str,
_layout: ViewLayout,
) -> FutureResult<EncodedCollab, FlowyError> {
// Database view doesn't support collab
FutureResult::new(async move { Err(FlowyError::not_support()) })
user: Arc<dyn FolderUser>,
view_id: &str,
) -> FutureResult<EncodedCollabWrapper, FlowyError> {
let manager = self.0.clone();
let view_id = view_id.to_string();
FutureResult::new(async move {
// get the collab_object_id for the database.
//
// the collab object_id for the database is not the view_id,
// we should use the view_id to get the database_id
let oid = manager.get_database_id_with_view_id(&view_id).await?;
let row_oids = manager.get_database_row_ids_with_view_id(&view_id).await?;
let row_oids = row_oids
.into_iter()
.map(|oid| oid.into_inner())
.collect::<Vec<_>>();
let database_metas = manager.get_all_databases_meta().await;
let uid = user
.user_id()
.map_err(|e| e.with_context("unable to get the uid: {}"))?;
// get the collab db
let collab_db = user
.collab_db(uid)
.map_err(|e| e.with_context("unable to get the collab"))?;
let collab_db = collab_db.upgrade().ok_or_else(|| {
FlowyError::internal().with_context(
"The collab db has been dropped, indicating that the user has switched to a new account",
)
})?;
let collab_read_txn = collab_db.read_txn();
// read the database collab from the db
let database_collab = load_collab_by_object_id(uid, &collab_read_txn, &oid).map_err(|e| {
FlowyError::internal().with_context(format!("load database collab failed: {}", e))
})?;
let database_encoded_collab = database_collab
// encode the collab and check the integrity of the collab
.encode_collab_v1(|collab| CollabType::Database.validate_require_data(collab))
.map_err(|e| {
FlowyError::internal().with_context(format!("encode database collab failed: {}", e))
})?;
// read the database rows collab from the db
let database_row_collabs = load_collab_by_object_ids(uid, &collab_read_txn, &row_oids);
let database_row_encoded_collabs = database_row_collabs
.into_iter()
.map(|(oid, collab)| {
// encode the collab and check the integrity of the collab
let encoded_collab = collab
.encode_collab_v1(|collab| CollabType::DatabaseRow.validate_require_data(collab))
.map_err(|e| {
FlowyError::internal()
.with_context(format!("encode database row collab failed: {}", e))
})?;
Ok((oid, encoded_collab))
})
.collect::<Result<HashMap<_, _>, FlowyError>>()?;
// get the relation info from the database meta
let database_relations = database_metas
.into_iter()
.filter_map(|meta| {
let linked_views = meta.linked_views.into_iter().next()?;
Some((meta.database_id, linked_views))
})
.collect::<HashMap<_, _>>();
Ok(EncodedCollabWrapper::Database(DatabaseEncodedCollab {
database_encoded_collab,
database_row_encoded_collabs,
database_relations,
}))
})
}
fn duplicate_view(&self, view_id: &str) -> FutureResult<Bytes, FlowyError> {
@ -567,13 +673,4 @@ impl FolderOperationHandler for ChatFolderOperation {
) -> FutureResult<(), FlowyError> {
FutureResult::new(async move { Err(FlowyError::not_support()) })
}
fn encoded_collab_v1(
&self,
_view_id: &str,
_layout: ViewLayout,
) -> FutureResult<EncodedCollab, FlowyError> {
// Chat view doesn't support collab
FutureResult::new(async move { Err(FlowyError::not_support()) })
}
}

View File

@ -32,7 +32,7 @@ use flowy_error::{FlowyError, FlowyResult};
use flowy_folder_pub::cloud::{
FolderCloudService, FolderCollabParams, FolderData, FolderSnapshot, Workspace, WorkspaceRecord,
};
use flowy_folder_pub::entities::{PublishInfoResponse, PublishViewPayload};
use flowy_folder_pub::entities::{PublishInfoResponse, PublishPayload};
use flowy_server_pub::af_cloud_config::AFCloudConfiguration;
use flowy_server_pub::supabase_config::SupabaseConfiguration;
use flowy_storage_pub::cloud::{ObjectIdentity, ObjectValue, StorageCloudService};
@ -307,7 +307,7 @@ impl FolderCloudService for ServerProvider {
fn publish_view(
&self,
workspace_id: &str,
payload: Vec<PublishViewPayload>,
payload: Vec<PublishPayload>,
) -> FutureResult<(), Error> {
let workspace_id = workspace_id.to_string();
let server = self.get_server();

View File

@ -169,6 +169,7 @@ impl DatabaseManager {
})?;
let lock_guard = database_collab.lock();
Ok(lock_guard.get_inline_view_id())
}
@ -206,6 +207,11 @@ impl DatabaseManager {
})
}
pub async fn get_database_row_ids_with_view_id(&self, view_id: &str) -> FlowyResult<Vec<RowId>> {
let database = self.get_database_with_view_id(view_id).await?;
Ok(database.get_row_ids())
}
pub async fn get_database(&self, database_id: &str) -> FlowyResult<Arc<DatabaseEditor>> {
if let Some(editor) = self.editors.lock().await.get(database_id).cloned() {
return Ok(editor);

View File

@ -93,6 +93,17 @@ impl DatabaseEditor {
self.database_views.close_view(view_id).await;
}
pub fn get_row_ids(&self) -> Vec<RowId> {
self
.database
.lock()
.block
.rows
.iter()
.map(|entry| entry.key().clone())
.collect()
}
pub async fn num_views(&self) -> usize {
self.database_views.num_editors().await
}

View File

@ -42,7 +42,7 @@ pub(crate) async fn get_encode_collab_handler(
let manager = upgrade_document(manager)?;
let params: OpenDocumentParams = data.into_inner().try_into()?;
let doc_id = params.document_id;
let state = manager.encode_collab(&doc_id).await?;
let state = manager.get_encoded_collab_with_view_id(&doc_id).await?;
data_result_ok(EncodedCollabPB {
state_vector: Vec::from(state.state_vector),
doc_state: Vec::from(state.doc_state),

View File

@ -11,7 +11,6 @@ use collab_document::document_awareness::DocumentAwarenessState;
use collab_document::document_awareness::DocumentAwarenessUser;
use collab_document::document_data::default_document_data;
use collab_entity::CollabType;
use collab_plugins::local_storage::kv::PersistenceError;
use collab_plugins::CollabKVDB;
use dashmap::DashMap;
use lib_infra::util::timestamp;
@ -76,7 +75,7 @@ impl DocumentManager {
}
/// Get the encoded collab of the document.
pub async fn encode_collab(&self, doc_id: &str) -> FlowyResult<EncodedCollab> {
pub async fn get_encoded_collab_with_view_id(&self, doc_id: &str) -> FlowyResult<EncodedCollab> {
let doc_state = DataSource::Disk;
let uid = self.user_service.user_id()?;
let collab = self
@ -85,7 +84,7 @@ impl DocumentManager {
let collab = collab.lock();
collab
.encode_collab_v1(|_| Ok::<(), PersistenceError>(()))
.encode_collab_v1(|collab| CollabType::Document.validate_require_data(collab))
.map_err(internal_error)
}

View File

@ -3,7 +3,7 @@ use collab_entity::CollabType;
pub use collab_folder::{Folder, FolderData, Workspace};
use uuid::Uuid;
use crate::entities::{PublishInfoResponse, PublishViewPayload};
use crate::entities::{PublishInfoResponse, PublishPayload};
use lib_infra::future::FutureResult;
/// [FolderCloudService] represents the cloud service for folder.
@ -49,7 +49,7 @@ pub trait FolderCloudService: Send + Sync + 'static {
fn publish_view(
&self,
workspace_id: &str,
payload: Vec<PublishViewPayload>,
payload: Vec<PublishPayload>,
) -> FutureResult<(), Error>;
fn unpublish_views(&self, workspace_id: &str, view_ids: Vec<String>) -> FutureResult<(), Error>;

View File

@ -1,6 +1,6 @@
use crate::folder_builder::ParentChildViews;
use collab_folder::{ViewIcon, ViewLayout};
use serde::Serialize;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
pub enum ImportData {
@ -70,17 +70,51 @@ pub struct PublishViewMeta {
pub publish_name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, Eq, PartialEq)]
pub struct PublishDatabaseData {
/// The encoded collab data for the database itself
pub database_collab: Vec<u8>,
/// The encoded collab data for the database rows
/// Use the row_id as the key
pub database_row_collabs: HashMap<String, Vec<u8>>,
/// The encoded collab data for the documents inside the database rows
/// It's not used for now
pub database_row_document_collabs: HashMap<String, Vec<u8>>,
/// Visible view ids
pub visible_database_view_ids: Vec<String>,
/// Relation view id map
pub database_relations: HashMap<String, String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PublishViewPayload {
pub struct PublishDocumentPayload {
pub meta: PublishViewMeta,
/// The doc_state of the encoded collab.
/// The encoded collab data for the document
pub data: Vec<u8>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PublishDatabasePayload {
pub meta: PublishViewMeta,
pub data: PublishDatabaseData,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum PublishPayload {
Document(PublishDocumentPayload),
Database(PublishDatabasePayload),
Unknown,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PublishInfoResponse {
pub view_id: String,
/// one part of publish url: /{namespace}/{publish_name}
pub publish_name: String,
/// One part of publish url: /{namespace}/{publish_name}
pub namespace: Option<String>,
pub publish_name: String,
}

View File

@ -1,12 +1,18 @@
use flowy_derive::ProtoBuf;
use flowy_folder_pub::entities::PublishInfoResponse;
use super::RepeatedViewIdPB;
#[derive(Default, ProtoBuf)]
pub struct PublishViewParamsPB {
#[pb(index = 1)]
pub view_id: String,
#[pb(index = 2, one_of)]
pub publish_name: Option<String>,
#[pb(index = 3, one_of)]
pub selected_view_ids: Option<RepeatedViewIdPB>,
}
#[derive(Default, ProtoBuf)]

View File

@ -411,8 +411,13 @@ pub(crate) async fn publish_view_handler(
) -> Result<(), FlowyError> {
let folder = upgrade_folder(folder)?;
let params = data.into_inner();
let selected_view_ids = params.selected_view_ids.map(|ids| ids.items);
folder
.publish_view(params.view_id.as_str(), params.publish_name)
.publish_view(
params.view_id.as_str(),
params.publish_name,
selected_view_ids,
)
.await?;
Ok(())
}
@ -428,7 +433,7 @@ pub(crate) async fn unpublish_views_handler(
Ok(())
}
#[tracing::instrument(level = "debug", skip(data, folder), err)]
#[tracing::instrument(level = "debug", skip(data, folder))]
pub(crate) async fn get_publish_info_handler(
data: AFPluginData<ViewIdPB>,
folder: AFPluginState<Weak<FolderManager>>,

View File

@ -17,7 +17,9 @@ use crate::share::{ImportParams, ImportValue};
use crate::util::{
folder_not_init_error, insert_parent_child_views, workspace_data_not_sync_error,
};
use crate::view_operation::{create_view, FolderOperationHandler, FolderOperationHandlers};
use crate::view_operation::{
create_view, EncodedCollabWrapper, FolderOperationHandler, FolderOperationHandlers,
};
use collab::core::collab::{DataSource, MutexCollab};
use collab_entity::{CollabType, EncodedCollab};
use collab_folder::error::FolderError;
@ -30,13 +32,15 @@ use collab_integrate::CollabKVDB;
use flowy_error::{internal_error, ErrorCode, FlowyError, FlowyResult};
use flowy_folder_pub::cloud::{gen_view_id, FolderCloudService, FolderCollabParams};
use flowy_folder_pub::entities::{
PublishInfoResponse, PublishViewInfo, PublishViewMeta, PublishViewMetaData, PublishViewPayload,
PublishDatabaseData, PublishDatabasePayload, PublishDocumentPayload, PublishInfoResponse,
PublishPayload, PublishViewInfo, PublishViewMeta, PublishViewMetaData,
};
use flowy_folder_pub::folder_builder::ParentChildViews;
use flowy_search_pub::entities::FolderIndexManager;
use flowy_sqlite::kv::KVStorePreferences;
use futures::future;
use parking_lot::RwLock;
use std::collections::HashMap;
use std::fmt::{Display, Formatter};
use std::ops::Deref;
use std::sync::{Arc, Weak};
@ -1029,28 +1033,50 @@ impl FolderManager {
Ok(())
}
/// Publish the view with the given view id.
/// [publish_name] is one part of the URL of the published view. if it is None, the default publish name will be used. The default publish name is generated by the view id and view name.
/// Publishes a view identified by the given `view_id`.
///
/// If `publish_name` is `None`, a default name will be generated using the view name and view id.
#[tracing::instrument(level = "debug", skip(self), err)]
pub async fn publish_view(&self, view_id: &str, publish_name: Option<String>) -> FlowyResult<()> {
pub async fn publish_view(
&self,
view_id: &str,
publish_name: Option<String>,
selected_view_ids: Option<Vec<String>>,
) -> FlowyResult<()> {
let view = self
.with_folder(|| None, |folder| folder.views.get_view(view_id))
.ok_or_else(|| FlowyError::record_not_found().with_context("Can't find the view"))?;
.ok_or_else(|| {
FlowyError::record_not_found()
.with_context(format!("Can't find the view with ID: {}", view_id))
})?;
let layout = view.layout.clone();
if layout != ViewLayout::Document {
if view.layout == ViewLayout::Chat {
return Err(FlowyError::new(
ErrorCode::NotSupportYet,
"Only document view can be published".to_string(),
"The chat view is not supported to publish.".to_string(),
));
}
// Get the view payload and its child views recursively
// Retrieve the view payload and its child views recursively
let payload = self
.get_batch_publish_payload(view_id, publish_name, Some(false))
.get_batch_publish_payload(view_id, publish_name, false)
.await?;
// set the selected view ids to the payload
let payload = if let Some(selected_view_ids) = selected_view_ids {
payload
.into_iter()
.map(|mut p| {
if let PublishPayload::Database(p) = &mut p {
p.data.visible_database_view_ids = selected_view_ids.clone();
}
p
})
.collect::<Vec<_>>()
} else {
payload
};
let workspace_id = self.user.workspace_id()?;
self
.cloud_service
@ -1101,14 +1127,18 @@ impl FolderManager {
Ok(namespace)
}
/// Get the publishing payload of the view with the given view id.
/// The publishing payload contains the view data and its child views(not recursively).
/// Retrieves the publishing payload for a specified view and optionally its child views.
///
/// # Arguments
/// * `view_id` - The ID of the view to publish.
/// * `publish_name` - Optional name for the published view.
/// * `include_children` - Flag to include child views in the payload.
pub async fn get_batch_publish_payload(
&self,
view_id: &str,
publish_name: Option<String>,
include_children: Option<bool>,
) -> FlowyResult<Vec<PublishViewPayload>> {
include_children: bool,
) -> FlowyResult<Vec<PublishPayload>> {
let mut stack = vec![view_id.to_string()];
let mut payloads = Vec::new();
@ -1118,12 +1148,12 @@ impl FolderManager {
Err(_) => continue,
};
// Only document view can be published
let layout = if view.layout == ViewLayoutPB::Document {
ViewLayout::Document
} else {
// Skip the chat view
if view.layout == ViewLayoutPB::Chat {
continue;
};
}
let layout: ViewLayout = view.layout.into();
// Only support set the publish_name for the current view, not for the child views
let publish_name = if current_view_id == view_id {
@ -1132,19 +1162,16 @@ impl FolderManager {
None
};
let payload = self
if let Ok(payload) = self
.get_publish_payload(&current_view_id, publish_name, layout)
.await;
if let Ok(payload) = payload {
.await
{
payloads.push(payload);
}
if include_children.unwrap_or(false) {
if include_children {
// Add the child views to the stack
for child in &view.child_views {
stack.push(child.id.clone());
}
stack.extend(view.child_views.iter().map(|child| child.id.clone()));
}
}
@ -1182,24 +1209,26 @@ impl FolderManager {
Some(view)
}
async fn get_publish_payload(
&self,
view_id: &str,
publish_name: Option<String>,
layout: ViewLayout,
) -> FlowyResult<PublishViewPayload> {
let handler = self.get_handler(&layout)?;
let encoded_collab = handler.encoded_collab_v1(view_id, layout).await?;
let view = self
.with_folder(|| None, |folder| folder.views.get_view(view_id))
.ok_or_else(|| FlowyError::record_not_found().with_context("Can't find the view"))?;
) -> FlowyResult<PublishPayload> {
let handler: Arc<dyn FolderOperationHandler + Sync + Send> = self.get_handler(&layout)?;
let encoded_collab_wrapper: EncodedCollabWrapper = handler
.get_encoded_collab_v1_from_disk(self.user.clone(), view_id)
.await?;
let view = self.get_view_pb(view_id).await?;
let publish_name = publish_name.unwrap_or_else(|| generate_publish_name(&view.id, &view.name));
let child_views = self
.build_publish_views(view_id)
.await
.map(|v| v.child_views.map_or(vec![], |c| c))
.map_or(vec![], |c| c);
.and_then(|v| v.child_views)
.unwrap_or_default();
let ancestor_views = self
.get_view_ancestors_pb(view_id)
@ -1208,9 +1237,8 @@ impl FolderManager {
.map(view_pb_to_publish_view)
.collect::<Vec<PublishViewInfo>>();
let view_pb = self.get_view_pb(view_id).await?;
let metadata = PublishViewMetaData {
view: view_pb_to_publish_view(&view_pb),
view: view_pb_to_publish_view(&view),
child_views,
ancestor_views,
};
@ -1220,8 +1248,32 @@ impl FolderManager {
metadata,
};
let data = Vec::from(encoded_collab.doc_state);
Ok(PublishViewPayload { meta, data })
let payload = match encoded_collab_wrapper {
EncodedCollabWrapper::Database(v) => {
let database_collab = v.database_encoded_collab.doc_state.to_vec();
let database_relations = v.database_relations;
let database_row_collabs = v
.database_row_encoded_collabs
.into_iter()
.map(|v| (v.0, v.1.doc_state.to_vec())) // Convert to HashMap
.collect::<HashMap<String, Vec<u8>>>();
let data = PublishDatabaseData {
database_collab,
database_row_collabs,
database_relations,
..Default::default()
};
PublishPayload::Database(PublishDatabasePayload { meta, data })
},
EncodedCollabWrapper::Document(v) => {
let data = v.document_encoded_collab.doc_state.to_vec();
PublishPayload::Document(PublishDocumentPayload { meta, data })
},
EncodedCollabWrapper::Unknown => PublishPayload::Unknown,
};
Ok(payload)
}
// Used by toggle_favorites to send notification to frontend, after the favorite status of view has been changed.It sends two distinct notifications: one to correctly update the concerned view's is_favorite status, and another to update the list of favorites that is to be displayed.

View File

@ -14,10 +14,30 @@ use lib_infra::future::FutureResult;
use lib_infra::util::timestamp;
use crate::entities::{CreateViewParams, ViewLayoutPB};
use crate::manager::FolderUser;
use crate::share::ImportType;
pub type ViewData = Bytes;
#[derive(Debug, Clone)]
pub enum EncodedCollabWrapper {
Document(DocumentEncodedCollab),
Database(DatabaseEncodedCollab),
Unknown,
}
#[derive(Debug, Clone)]
pub struct DocumentEncodedCollab {
pub document_encoded_collab: EncodedCollab,
}
#[derive(Debug, Clone)]
pub struct DatabaseEncodedCollab {
pub database_encoded_collab: EncodedCollab,
pub database_row_encoded_collabs: HashMap<String, EncodedCollab>,
pub database_relations: HashMap<String, String>,
}
/// The handler will be used to handler the folder operation for a specific
/// view layout. Each [ViewLayout] will have a handler. So when creating a new
/// view, the [ViewLayout] will be used to get the handler.
@ -45,11 +65,14 @@ pub trait FolderOperationHandler {
/// Returns the [ViewData] that can be used to create the same view.
fn duplicate_view(&self, view_id: &str) -> FutureResult<ViewData, FlowyError>;
fn encoded_collab_v1(
/// get the encoded collab data from the disk.
fn get_encoded_collab_v1_from_disk(
&self,
view_id: &str,
layout: ViewLayout,
) -> FutureResult<EncodedCollab, FlowyError>;
_user: Arc<dyn FolderUser>,
_view_id: &str,
) -> FutureResult<EncodedCollabWrapper, FlowyError> {
FutureResult::new(async move { Err(FlowyError::not_support()) })
}
/// Create a view with the data.
///

View File

@ -7,6 +7,7 @@ use collab::core::collab::DataSource;
use collab::core::origin::CollabOrigin;
use collab_entity::CollabType;
use collab_folder::RepeatedViewIdentifier;
use serde_json::to_vec;
use std::sync::Arc;
use tracing::instrument;
use uuid::Uuid;
@ -16,7 +17,7 @@ use flowy_folder_pub::cloud::{
Folder, FolderCloudService, FolderCollabParams, FolderData, FolderSnapshot, Workspace,
WorkspaceRecord,
};
use flowy_folder_pub::entities::{PublishInfoResponse, PublishViewPayload};
use flowy_folder_pub::entities::{PublishInfoResponse, PublishPayload};
use lib_infra::future::FutureResult;
use crate::af_cloud::define::ServerUser;
@ -187,22 +188,31 @@ where
fn publish_view(
&self,
workspace_id: &str,
payload: Vec<PublishViewPayload>,
payload: Vec<PublishPayload>,
) -> FutureResult<(), Error> {
let workspace_id = workspace_id.to_string();
let try_get_client = self.inner.try_get_client();
FutureResult::new(async move {
let params = payload
.into_iter()
.map(|object| PublishCollabItem {
meta: PublishCollabMetadata {
view_id: Uuid::parse_str(object.meta.view_id.as_str()).unwrap_or(Uuid::nil()),
publish_name: object.meta.publish_name,
metadata: object.meta.metadata,
let params = payload
.into_iter()
.filter_map(|object| {
let (meta, data) = match object {
PublishPayload::Document(payload) => (payload.meta, payload.data),
PublishPayload::Database(payload) => {
(payload.meta, to_vec(&payload.data).unwrap_or_default())
},
data: object.data,
PublishPayload::Unknown => return None,
};
Some(PublishCollabItem {
meta: PublishCollabMetadata {
view_id: Uuid::parse_str(&meta.view_id).unwrap_or(Uuid::nil()),
publish_name: meta.publish_name,
metadata: meta.metadata,
},
data,
})
.collect::<Vec<_>>();
})
.collect::<Vec<_>>();
FutureResult::new(async move {
try_get_client?
.publish_collabs(&workspace_id, params)
.await

View File

@ -7,7 +7,7 @@ use flowy_folder_pub::cloud::{
gen_workspace_id, FolderCloudService, FolderCollabParams, FolderData, FolderSnapshot, Workspace,
WorkspaceRecord,
};
use flowy_folder_pub::entities::{PublishInfoResponse, PublishViewPayload};
use flowy_folder_pub::entities::{PublishInfoResponse, PublishPayload};
use lib_infra::future::FutureResult;
use crate::local_server::LocalServerDB;
@ -82,7 +82,7 @@ impl FolderCloudService for LocalServerFolderCloudServiceImpl {
fn publish_view(
&self,
_workspace_id: &str,
_payload: Vec<PublishViewPayload>,
_payload: Vec<PublishPayload>,
) -> FutureResult<(), Error> {
FutureResult::new(async { Err(anyhow!("Local server doesn't support publish view")) })
}

View File

@ -13,7 +13,7 @@ use flowy_folder_pub::cloud::{
gen_workspace_id, Folder, FolderCloudService, FolderCollabParams, FolderData, FolderSnapshot,
Workspace, WorkspaceRecord,
};
use flowy_folder_pub::entities::{PublishInfoResponse, PublishViewPayload};
use flowy_folder_pub::entities::{PublishInfoResponse, PublishPayload};
use lib_dispatch::prelude::af_spawn;
use lib_infra::future::FutureResult;
use lib_infra::util::timestamp;
@ -176,7 +176,7 @@ where
fn publish_view(
&self,
_workspace_id: &str,
_payload: Vec<PublishViewPayload>,
_payload: Vec<PublishPayload>,
) -> FutureResult<(), Error> {
FutureResult::new(async { Err(anyhow!("supabase server doesn't support publish view")) })
}

View File

@ -1,6 +1,6 @@
use crate::migrations::session_migration::migrate_session_with_user_uuid;
use crate::services::data_import::importer::load_collab_by_oid;
use crate::services::data_import::importer::load_collab_by_object_ids;
use crate::services::db::UserDBPath;
use crate::services::entities::UserPaths;
use crate::services::sqlite_sql::user_sql::select_user_profile;
@ -200,7 +200,7 @@ pub(crate) fn generate_import_data(
all_imported_object_ids.retain(|id| !database_view_ids.contains(id));
// 3. load imported collab objects data.
let imported_collab_by_oid = load_collab_by_oid(
let imported_collab_by_oid = load_collab_by_object_ids(
imported_session.user_id,
&imported_collab_read_txn,
&all_imported_object_ids,
@ -914,7 +914,7 @@ where
R: CollabKVAction<'a>,
PersistenceError: From<R::Error>,
{
load_collab_by_oid(uid, collab_read, object_ids)
load_collab_by_object_ids(uid, collab_read, object_ids)
.into_iter()
.filter_map(|(oid, collab)| {
collab

View File

@ -3,8 +3,9 @@ use collab_integrate::{CollabKVAction, PersistenceError};
use std::collections::HashMap;
use tracing::instrument;
/// This function loads collab objects by their object_ids.
#[instrument(level = "debug", skip_all)]
pub fn load_collab_by_oid<'a, R>(
pub fn load_collab_by_object_ids<'a, R>(
uid: i64,
collab_read_txn: &R,
object_ids: &[String],
@ -14,17 +15,32 @@ where
PersistenceError: From<R::Error>,
{
let mut collab_by_oid = HashMap::new();
for object_id in object_ids {
let collab = Collab::new(uid, object_id, "phantom", vec![], false);
match collab
.with_origin_transact_mut(|txn| collab_read_txn.load_doc_with_txn(uid, &object_id, txn))
{
Ok(_) => {
match load_collab_by_object_id(uid, collab_read_txn, object_id) {
Ok(collab) => {
collab_by_oid.insert(object_id.clone(), collab);
},
Err(err) => tracing::error!("🔴import collab:{} failed: {:?} ", object_id, err),
Err(err) => tracing::error!("🔴load collab: {} failed: {:?} ", object_id, err),
}
}
collab_by_oid
}
/// This function loads single collab object by its object_id.
#[instrument(level = "debug", skip_all)]
pub fn load_collab_by_object_id<'a, R>(
uid: i64,
collab_read_txn: &R,
object_id: &String,
) -> Result<Collab, PersistenceError>
where
R: CollabKVAction<'a>,
PersistenceError: From<R::Error>,
{
let collab = Collab::new(uid, object_id, "phantom", vec![], false);
collab
.with_origin_transact_mut(|txn| collab_read_txn.load_doc_with_txn(uid, object_id, txn))
.map(|_| collab)
}

View File

@ -2,4 +2,5 @@ mod appflowy_data_import;
pub use appflowy_data_import::*;
pub(crate) mod importer;
pub use importer::load_collab_by_oid;
pub use importer::load_collab_by_object_id;
pub use importer::load_collab_by_object_ids;