Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add template #3032

Open
wants to merge 29 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
99ecf59
feat: add template
AmanNegi Jul 20, 2023
646876a
feat: add integration tests
AmanNegi Jul 22, 2023
53803ab
chore: merge with main
AmanNegi Aug 8, 2023
2b929ad
fix: update integration test
AmanNegi Aug 8, 2023
6301d1b
feat: Fix tests and template button
AmanNegi Aug 11, 2023
f92734b
chore: merge with main
AmanNegi Aug 17, 2023
118f871
chore: fix errors
AmanNegi Aug 17, 2023
8d4cf4b
fix: analyzing issue
AmanNegi Aug 17, 2023
f9c069d
feat: Support nested pages
AmanNegi Aug 25, 2023
01deb81
chore: merge with main
AmanNegi Aug 25, 2023
0bb9290
chore: add tests to runner
AmanNegi Aug 29, 2023
b275039
chore: merge with main
AmanNegi Aug 30, 2023
d893f7a
feat: add image support
AmanNegi Sep 7, 2023
81a4b0f
chore: merge with main
AmanNegi Sep 7, 2023
5a92cda
chore: update i8n
AmanNegi Sep 8, 2023
2661fe4
chore: merge with main
AmanNegi Oct 5, 2023
ebd643d
Merge branch 'main' into feat-create-template
AmanNegi Oct 5, 2023
512b9d4
chore: merge with main
AmanNegi Oct 10, 2023
e9e2021
chore: merge with main
AmanNegi Oct 10, 2023
21ce591
chore: merge with 'main'
AmanNegi Oct 14, 2023
b6e1cb6
feat: export template as zip
AmanNegi Oct 14, 2023
f9c44a8
Merge branch 'main' of https://github.com/AmanNegi/AppFlowy into feat…
AmanNegi Oct 17, 2023
5625cf0
chore: merge with main
AmanNegi Dec 11, 2023
56abf2c
fix: minor bugs
AmanNegi Dec 11, 2023
b8494f8
chore: add initial templates
AmanNegi Dec 13, 2023
815e864
feat: add support for referenced grid
AmanNegi Dec 21, 2023
39b07fc
chore: Merge branch 'main' of https://github.com/AmanNegi/AppFlowy in…
AmanNegi Dec 21, 2023
441fbe9
feat: add template tests
AmanNegi Dec 22, 2023
8420071
chore: merge with main branch
AmanNegi Dec 22, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
AmanNegi marked this conversation as resolved.
Show resolved Hide resolved

import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';

import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'dart:io';

import 'package:appflowy/plugins/document/presentation/editor_plugins/base/link_to_page_widget.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/protobuf.dart';
import 'package:flowy_infra/uuid.dart';

import '../util/mock/mock_file_picker.dart';
import '../util/util.dart';

void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();

group('document template test', () {
testWidgets('export a template with referenced grid', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();

await insertReferenceDatabase(tester, ViewLayoutPB.Grid);
await tester.openPage("template");

await tester.editor.hoverOnCoverToolbar();
await tester.tapButtonWithName("Convert to JSON");
AmanNegi marked this conversation as resolved.
Show resolved Hide resolved

final tempDir = await getApplicationDocumentsDirectory();
debugPrint("$tempDir");

expect(await Directory("${tempDir.path}/template").exists(), isTrue);

expect(
await File("${tempDir.path}/template/template.json").exists(),
isTrue,
);

expect(
await File("${tempDir.path}/template/db1.csv").exists(),
isTrue,
);
});

testWidgets('import a template', (tester) async {
final context = await tester.initializeAppFlowy();
await tester.tapGoButton();

await tester.createNewPageWithName(
name: 'template',
layout: ViewLayoutPB.Document,


);

const zipFileName = 'template.zip';
final data = await rootBundle.load('assets/test/workspaces/$zipFileName');
final bytes = Uint8List.view(data.buffer);
final path = p.join(context.applicationDataDirectory, zipFileName);
File(path).writeAsBytesSync(bytes);

// mock get files
await mockPickFilePaths(paths: [path]);

// tap the first line of the document
await tester.editor.tapLineOfEditorAt(0);

await tester.editor.hoverOnCoverToolbar();
await tester.tapButtonWithName("Add Template");
});
});
}

/// Insert a referenced database of [layout] into the document
Future<void> insertReferenceDatabase(
WidgetTester tester,
ViewLayoutPB layout,
) async {
// create a new grid
final id = uuid();
final name = '${layout.name}_$id';
await tester.createNewPageWithName(
name: name,
layout: layout,
);

// create a new document
await tester.createNewPageWithName(
name: 'template',
layout: ViewLayoutPB.Document,
);
// tap the first line of the document
await tester.editor.tapLineOfEditorAt(0);
// insert a referenced view
await tester.editor.showSlashMenu();
await tester.editor.tapSlashMenuItemWithName(
layout.referencedMenuName,
);

final linkToPageMenu = find.byType(LinkToPageMenu);
expect(linkToPageMenu, findsOneWidget);
final referencedDatabase = find.descendant(
of: linkToPageMenu,
matching: find.findTextInFlowyText(name),
);
expect(referencedDatabase, findsOneWidget);
await tester.tapButton(referencedDatabase);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
import 'dart:convert';
import 'dart:io';
import 'package:path/path.dart' as path;

import 'package:flutter/material.dart';
import 'package:archive/archive.dart';
import 'package:path_provider/path_provider.dart';

import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart';
import 'package:appflowy/workspace/application/settings/share/export_service.dart';
import 'package:appflowy/workspace/application/settings/share/import_service.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/import.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';

import 'package:flowy_infra/file_picker/file_picker_impl.dart';
import 'package:flowy_infra/file_picker/file_picker_service.dart';

class TemplateService {
Future<void> saveTemplate(EditorState editorState) async {
final directory = await getApplicationDocumentsDirectory();

final dir = Directory(path.join(directory.path, 'template'));
if (!(await dir.exists())) {
await dir.create(recursive: true);
} else {
// TODO: Show an alert dialog before overwriting the template
for (final entity in dir.listSync()) {
if (entity is File) {
entity.deleteSync();
} else if (entity is Directory) {
entity.deleteSync(recursive: true);
}
}
}

final file = File('${directory.path}/template/template.json');
AmanNegi marked this conversation as resolved.
Show resolved Hide resolved

await file.writeAsString(json.encode(editorState.document.toJson()));

final Map<String, dynamic> jsonData = editorState.document.toJson();

final List<Map<String, dynamic>> children =
jsonData["document"]["children"];

int count = 1;
for (int i = 0; i < children.length; i++) {
// TODO: add feat to add calendar
if (children[i]["type"] == "grid" || children[i]["type"] == "board") {
AmanNegi marked this conversation as resolved.
Show resolved Hide resolved
_exportDBFiles("db${count++}", children[i]["data"]["view_id"]);
}
}
}

Future<void> _exportDBFiles(String name, String viewId) async {
final directory = await getApplicationDocumentsDirectory();

final res = await BackendExportService.exportDatabaseAsCSV(viewId);
final String? pb = res.fold((l) => l.data, (r) => null);

if (pb == null) return;

final dbFile = File('${directory.path}/template/$name.csv');
await dbFile.writeAsString(pb);
}

/// Steps:
/// 1. Pick template(.zip)
/// 2. Zip may contain several files, use [config.json] to determine which files to use.
/// 3. Load template into editor, using [TemplateService.unloadTemplate] function

Future<Archive?> pickTemplate() async {
// Pick a ZIP file from the system
final result = await FilePicker().pickFiles(
type: FileType.custom,
allowedExtensions: ['zip'],
allowMultiple: false,
);

// User cancelled the picker
if (result == null) return null;

// Extract the contents of the ZIP file
final file = File(result.files.single.path!);

final contents = await file.readAsBytes();
final archive = ZipDecoder().decodeBytes(contents);

return archive;
}

Future<void> unloadTemplate(
String parentViewId,
Archive? archive,
) async {
if (archive == null) return;

final directory = await getTemporaryDirectory();

for (final file in archive) {
final filename = '${directory.path}/${file.name}';
final data = file.content as List<int>;
final outputFile = File(filename);
await outputFile.create(recursive: true);
await outputFile.writeAsBytes(data);
}

final config =
json.decode(await File("${directory.path}/config.json").readAsString());

final TemplateModel template = TemplateModel.fromJson(config);

debugPrint("Loading Template: ${template.templateName} into editor");

for (final DocumentModel doc in template.documents) {
_loadTemplate(parentViewId, doc);
}
}

Future<void> _loadTemplate(String parentViewId, DocumentModel doc) async {
final directory = await getTemporaryDirectory();

/// Import all databases first

final List<ViewPB> dbViews = [];

for (final db in doc.db) {
final res = await _importDB(db, parentViewId);
if (res == null) {
// Abort if an error occurred while importing the database
debugPrint("TemplateService: An error occurred while importing $db");
return;
}

dbViews.add(res);
}

/// Import the document and embed the [dbViews]

final String templateRes =
await File('${directory.path}/${doc.name}').readAsString();

final Map<String, dynamic> docJson = json.decode(templateRes);

final List<dynamic> children = docJson["document"]["children"];

int dbCounter = 0;

if (dbViews.isNotEmpty) {
for (final child in children) {
if (child["type"] == "grid" || child["type"] == "board") {
/// Update old ID's with new ID's
child["data"]["view_id"] = dbViews[dbCounter++].id;
child["data"]["parent_id"] = parentViewId;
}
}
}

final document = Document.fromJson({
"document": {
"type": "page",
AmanNegi marked this conversation as resolved.
Show resolved Hide resolved
"children": children,
}
});

final docBytes =
DocumentDataPBFromTo.fromDocument(document)?.writeToBuffer();

await ImportBackendService.importData(
docBytes!,
doc.name,
parentViewId,
ImportTypePB.HistoryDocument,
);
}
}

Future<ViewPB?> _importDB(String db, String parentViewId) async {
final directory = await getTemporaryDirectory();

final dbRes = await File('${directory.path}/$db').readAsString();

final res = await ImportBackendService.importData(
utf8.encode(dbRes),
db,
parentViewId,
ImportTypePB.CSV,
);

return res.fold((l) => l, (r) => null);
}

/// [TemplateModel] is the structure for [config.json] file

class TemplateModel {
String templateName;
List<DocumentModel> documents;

TemplateModel({
required this.templateName,
required this.documents,
});

factory TemplateModel.fromRawJson(String str) =>
TemplateModel.fromJson(json.decode(str));

String toRawJson() => json.encode(toJson());

factory TemplateModel.fromJson(Map<String, dynamic> json) => TemplateModel(
templateName: json["templateName"],
documents: List<DocumentModel>.from(
json["documents"].map((x) => DocumentModel.fromJson(x))),
);

Map<String, dynamic> toJson() => {
"templateName": templateName,
"documents": List<dynamic>.from(documents.map((x) => x.toJson())),
};
}

/// [DocumentModel] is the structure for each document in [config.json] file

class DocumentModel {
String name;
List<String> db;

DocumentModel({
required this.name,
required this.db,
});

factory DocumentModel.fromRawJson(String str) =>
DocumentModel.fromJson(json.decode(str));

String toRawJson() => json.encode(toJson());

factory DocumentModel.fromJson(Map<String, dynamic> json) => DocumentModel(
name: json["name"],
db: List<String>.from(json["db"].map((x) => x)),
);

Map<String, dynamic> toJson() => {
"name": name,
"db": List<dynamic>.from(db.map((x) => x)),
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ class _DocumentPageState extends State<DocumentPage> {
return DocumentHeaderNodeWidget(
node: page,
editorState: editorState!,
view: widget.view,
);
}

Expand Down
Loading