diff --git a/.github/workflows/integration_test.yml b/.github/workflows/integration_test.yml index 7138c0aa..73cc325d 100644 --- a/.github/workflows/integration_test.yml +++ b/.github/workflows/integration_test.yml @@ -44,20 +44,23 @@ jobs: - name: Build Docker Images run: | export DOCKER_DEFAULT_PLATFORM=linux/amd64 - docker compose build appflowy_cloud appflowy_history appflowy_worker + docker compose build appflowy_cloud appflowy_history appflowy_worker admin_frontend - name: Push docker images to docker hub run: | docker tag appflowyinc/appflowy_cloud appflowyinc/appflowy_cloud:${GITHUB_SHA} docker tag appflowyinc/appflowy_history appflowyinc/appflowy_history:${GITHUB_SHA} docker tag appflowyinc/appflowy_worker appflowyinc/appflowy_worker:${GITHUB_SHA} + docker tag appflowyinc/admin_frontend appflowyinc/admin_frontend:${GITHUB_SHA} echo ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} | docker login --username appflowyinc --password-stdin docker push appflowyinc/appflowy_cloud:${GITHUB_SHA} docker push appflowyinc/appflowy_history:${GITHUB_SHA} docker push appflowyinc/appflowy_worker:${GITHUB_SHA} + docker push appflowyinc/admin_frontend:${GITHUB_SHA} APPFLOWY_HISTORY_VERSION=${GITHUB_SHA} APPFLOWY_WORKER_VERSION=${GITHUB_SHA} APPFLOWY_CLOUD_VERSION=${GITHUB_SHA} + APPFLOWY_ADMIN_FRONTEND_VERSION=${GITHUB_SHA} test: name: Integration Tests @@ -72,6 +75,8 @@ jobs: test_cmd: "-p appflowy-history" - test_service: "appflowy_worker" test_cmd: "-p appflowy-worker" + - test_service: "admin_frontend" + test_cmd: "-p admin_frontend" steps: - uses: actions/checkout@v3 - uses: dtolnay/rust-toolchain@stable @@ -122,7 +127,7 @@ jobs: echo "No running container found to display logs." fi - - name: install prerequisites + - name: Install prerequisites run: | sudo apt-get update sudo apt-get install protobuf-compiler @@ -131,7 +136,6 @@ jobs: run: | echo "Running tests for ${{ matrix.test_service }} with flags: ${{ matrix.test_cmd }}" RUST_LOG="info" DISABLE_CI_TEST_LOG="true" cargo test ${{ matrix.test_cmd }} - RUST_LOG="info" DISABLE_CI_TEST_LOG="true" cargo test -p admin_frontend ${{ matrix.test_cmd }} - name: Run Tests from main branch run: | diff --git a/libs/client-api/src/http_collab.rs b/libs/client-api/src/http_collab.rs index de0cb7df..bafa0540 100644 --- a/libs/client-api/src/http_collab.rs +++ b/libs/client-api/src/http_collab.rs @@ -1,6 +1,7 @@ use crate::http::log_request_id; use crate::{blocking_brotli_compress, Client}; use app_error::AppError; +use client_api_entity::workspace_dto::AFDatabase; use client_api_entity::{ BatchQueryCollabParams, BatchQueryCollabResult, CreateCollabParams, DeleteCollabParams, QueryCollab, UpdateCollabWebParams, @@ -123,6 +124,7 @@ impl Client { .await? .into_data() } + #[instrument(level = "info", skip_all, err)] pub async fn delete_collab(&self, params: DeleteCollabParams) -> Result<(), AppResponseError> { let url = format!( @@ -138,4 +140,19 @@ impl Client { log_request_id(&resp); AppResponse::<()>::from_response(resp).await?.into_error() } + + #[instrument(level = "info", skip_all, err)] + pub async fn list_databases( + &self, + workspace_id: &str, + ) -> Result, AppResponseError> { + let url = format!("{}/api/workspace/{}/database", self.base_url, workspace_id); + let resp = self + .http_client_with_auth(Method::GET, &url) + .await? + .send() + .await?; + log_request_id(&resp); + AppResponse::from_response(resp).await?.into_data() + } } diff --git a/libs/client-api/src/http_view.rs b/libs/client-api/src/http_view.rs index dc179b4a..14101253 100644 --- a/libs/client-api/src/http_view.rs +++ b/libs/client-api/src/http_view.rs @@ -58,6 +58,23 @@ impl Client { AppResponse::<()>::from_response(resp).await?.into_error() } + pub async fn restore_all_workspace_page_views_from_trash( + &self, + workspace_id: Uuid, + ) -> Result<(), AppResponseError> { + let url = format!( + "{}/api/workspace/{}/restore-all-pages-from-trash", + self.base_url, workspace_id + ); + let resp = self + .http_client_with_auth(Method::POST, &url) + .await? + .json(&json!({})) + .send() + .await?; + AppResponse::<()>::from_response(resp).await?.into_error() + } + pub async fn update_workspace_page_view( &self, workspace_id: Uuid, diff --git a/libs/shared-entity/src/dto/workspace_dto.rs b/libs/shared-entity/src/dto/workspace_dto.rs index 629cf87c..ec6b4ea2 100644 --- a/libs/shared-entity/src/dto/workspace_dto.rs +++ b/libs/shared-entity/src/dto/workspace_dto.rs @@ -280,3 +280,16 @@ pub struct PublishedView { pub extra: Option, pub children: Vec, } + +#[derive(Default, Debug, Clone, Serialize, Deserialize)] +pub struct AFDatabase { + pub id: String, + pub name: String, + pub fields: Vec, +} + +#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct AFDatabaseField { + pub name: String, + pub field_type: String, +} diff --git a/src/api/workspace.rs b/src/api/workspace.rs index 51ac879e..39114eed 100644 --- a/src/api/workspace.rs +++ b/src/api/workspace.rs @@ -49,8 +49,8 @@ use crate::biz::workspace::ops::{ get_reactions_on_published_view, remove_comment_on_published_view, remove_reaction_on_comment, }; use crate::biz::workspace::page_view::{ - create_page, get_page_view_collab, move_page_to_trash, restore_page_from_trash, update_page, - update_page_collab_data, + create_page, get_page_view_collab, move_page_to_trash, restore_all_pages_from_trash, + restore_page_from_trash, update_page, update_page_collab_data, }; use crate::biz::workspace::publish::get_workspace_default_publish_view_info_meta; use crate::domain::compression::{ @@ -143,6 +143,10 @@ pub fn workspace_scope() -> Scope { web::resource("/{workspace_id}/page-view/{view_id}/restore-from-trash") .route(web::post().to(restore_page_from_trash_handler)), ) + .service( + web::resource("/{workspace_id}/restore-all-pages-from-trash") + .route(web::post().to(restore_all_pages_from_trash_handler)), + ) .service( web::resource("/{workspace_id}/batch/collab") .route(web::post().to(batch_create_collab_handler)), @@ -246,6 +250,7 @@ pub fn workspace_scope() -> Scope { // Web browser can't carry payload when using GET method, so for browser compatibility, we use POST method .route(web::post().to(batch_get_collab_handler)), ) + .service(web::resource("/{workspace_id}/database").route(web::get().to(list_database_handler))) } pub fn collab_scope() -> Scope { @@ -946,6 +951,23 @@ async fn restore_page_from_trash_handler( Ok(Json(AppResponse::Ok())) } +async fn restore_all_pages_from_trash_handler( + user_uuid: UserUuid, + path: web::Path, + state: Data, +) -> Result>> { + let uid = state.user_cache.get_user_uid(&user_uuid).await?; + let workspace_uuid = path.into_inner(); + restore_all_pages_from_trash( + &state.pg_pool, + &state.collab_access_control_storage, + uid, + workspace_uuid, + ) + .await?; + Ok(Json(AppResponse::Ok())) +} + async fn update_page_view_handler( user_uuid: UserUuid, path: web::Path<(Uuid, String)>, @@ -1375,6 +1397,7 @@ async fn post_published_duplicate_handler( params.dest_view_id, ) .await?; + Ok(Json(AppResponse::Ok())) } @@ -1774,6 +1797,23 @@ async fn get_workspace_publish_outline_handler( Ok(Json(AppResponse::Ok().with_data(published_view))) } +async fn list_database_handler( + user_uuid: UserUuid, + workspace_id: web::Path, + state: Data, +) -> Result>>> { + let uid = state.user_cache.get_user_uid(&user_uuid).await?; + let workspace_id = workspace_id.into_inner(); + let dbs = biz::collab::ops::list_database( + &state.pg_pool, + &state.collab_access_control_storage, + uid, + workspace_id, + ) + .await?; + Ok(Json(AppResponse::Ok().with_data(dbs))) +} + #[inline] async fn parser_realtime_msg( payload: Bytes, diff --git a/src/biz/collab/ops.rs b/src/biz/collab/ops.rs index efc9abbe..02e1707e 100644 --- a/src/biz/collab/ops.rs +++ b/src/biz/collab/ops.rs @@ -2,14 +2,23 @@ use std::sync::Arc; use app_error::AppError; use appflowy_collaborate::collab::storage::CollabAccessControlStorage; +use collab::preclude::Collab; +use collab_database::database::DatabaseBody; +use collab_database::entity::FieldType; +use collab_database::workspace_database::NoPersistenceDatabaseCollabService; +use collab_database::workspace_database::WorkspaceDatabaseBody; use collab_entity::CollabType; use collab_entity::EncodedCollab; use collab_folder::SectionItem; use collab_folder::{CollabOrigin, Folder}; +use database::collab::select_workspace_database_oid; use database::collab::{CollabStorage, GetCollabOrigin}; use database::publish::select_published_view_ids_for_workspace; use database::publish::select_workspace_id_for_publish_namespace; +use database_entity::dto::QueryCollabResult; use database_entity::dto::{QueryCollab, QueryCollabParams}; +use shared_entity::dto::workspace_dto::AFDatabase; +use shared_entity::dto::workspace_dto::AFDatabaseField; use shared_entity::dto::workspace_dto::FavoriteFolderView; use shared_entity::dto::workspace_dto::RecentFolderView; use shared_entity::dto::workspace_dto::TrashFolderView; @@ -348,3 +357,101 @@ pub async fn get_published_view( collab_folder_to_published_outline(&workspace_id.to_string(), &folder, &publish_view_ids)?; Ok(published_view) } + +pub async fn list_database( + pg_pool: &PgPool, + collab_storage: &CollabAccessControlStorage, + uid: i64, + workspace_uuid_str: String, +) -> Result, AppError> { + let workspace_uuid: Uuid = workspace_uuid_str.as_str().parse()?; + let ws_db_oid = select_workspace_database_oid(pg_pool, &workspace_uuid).await?; + + let ec = get_latest_collab_encoded( + collab_storage, + GetCollabOrigin::Server, + &workspace_uuid_str, + &ws_db_oid, + CollabType::WorkspaceDatabase, + ) + .await?; + let mut collab: Collab = + Collab::new_with_source(CollabOrigin::Server, &ws_db_oid, ec.into(), vec![], false).map_err( + |e| { + AppError::Internal(anyhow::anyhow!( + "Failed to create collab from encoded collab: {:?}", + e + )) + }, + )?; + + let ws_body = WorkspaceDatabaseBody::open(&mut collab).map_err(|e| { + AppError::Internal(anyhow::anyhow!( + "Failed to open workspace database body: {:?}", + e + )) + })?; + let db_metas = ws_body.get_all_meta(&collab.transact()); + let query_collabs: Vec = db_metas + .into_iter() + .map(|meta| QueryCollab { + object_id: meta.database_id.clone(), + collab_type: CollabType::Database, + }) + .collect(); + let results = collab_storage + .batch_get_collab(&uid, query_collabs, true) + .await; + + let txn = collab.transact(); + let mut af_databases: Vec = Vec::with_capacity(results.len()); + for (oid, result) in results { + match result { + QueryCollabResult::Success { encode_collab_v1 } => { + match EncodedCollab::decode_from_bytes(&encode_collab_v1) { + Ok(ec) => { + match Collab::new_with_source(CollabOrigin::Server, &oid, ec.into(), vec![], false) { + Ok(db_collab) => match DatabaseBody::from_collab( + &db_collab, + Arc::new(NoPersistenceDatabaseCollabService), + None, + ) { + Some(db_body) => match db_body.metas.get_inline_view_id(&txn) { + Some(iid) => match db_body.views.get_view(&txn, &iid) { + Some(iview) => { + let name = iview.name; + + let db_fields = db_body.fields.get_all_fields(&txn); + let mut af_fields: Vec = Vec::with_capacity(db_fields.len()); + for db_field in db_fields { + af_fields.push(AFDatabaseField { + name: db_field.name, + field_type: format!("{:?}", FieldType::from(db_field.field_type)), + }); + } + af_databases.push(AFDatabase { + id: db_body.get_database_id(&txn), + name, + fields: af_fields, + }); + }, + None => tracing::warn!("Failed to get inline view: {}", iid), + }, + None => tracing::error!("Failed to get inline view id for database: {}", oid), + }, + None => tracing::error!("Failed to create db_body from db_collab, oid: {}", oid), + }, + Err(err) => tracing::error!("Failed to create db_collab: {:?}", err), + } + }, + Err(err) => tracing::error!("Failed to decode collab: {:?}", err), + } + }, + QueryCollabResult::Failed { error } => { + tracing::warn!("Failed to get collab: {:?}", error) + }, + } + } + + Ok(af_databases) +} diff --git a/src/biz/workspace/page_view.rs b/src/biz/workspace/page_view.rs index 30d9cf11..b6e1fd9a 100644 --- a/src/biz/workspace/page_view.rs +++ b/src/biz/workspace/page_view.rs @@ -164,6 +164,25 @@ async fn move_view_out_from_trash( }) } +async fn move_all_views_out_from_trash(folder: &mut Folder) -> Result { + let encoded_update = { + let mut txn = folder.collab.transact_mut(); + if let Some(op) = folder + .body + .section + .section_op(&txn, collab_folder::Section::Trash) + { + op.clear(&mut txn); + }; + txn.encode_update_v1() + }; + + Ok(FolderUpdate { + updated_encoded_collab: folder_to_encoded_collab(folder)?, + encoded_updates: encoded_update, + }) +} + fn folder_to_encoded_collab(folder: &Folder) -> Result, AppError> { let collab_type = CollabType::Folder; let encoded_folder_collab = folder @@ -297,6 +316,29 @@ pub async fn restore_page_from_trash( Ok(()) } +pub async fn restore_all_pages_from_trash( + pg_pool: &PgPool, + collab_storage: &CollabAccessControlStorage, + uid: i64, + workspace_id: Uuid, +) -> Result<(), AppError> { + let collab_origin = GetCollabOrigin::User { uid }; + let mut folder = + get_latest_collab_folder(collab_storage, collab_origin, &workspace_id.to_string()).await?; + let folder_update = move_all_views_out_from_trash(&mut folder).await?; + let mut transaction = pg_pool.begin().await?; + insert_and_broadcast_workspace_folder_update( + uid, + workspace_id, + folder_update, + collab_storage, + &mut transaction, + ) + .await?; + transaction.commit().await?; + Ok(()) +} + #[allow(clippy::too_many_arguments)] pub async fn update_page( pg_pool: &PgPool, diff --git a/tests/workspace/page_view.rs b/tests/workspace/page_view.rs index 2e3d9cfb..d4e92239 100644 --- a/tests/workspace/page_view.rs +++ b/tests/workspace/page_view.rs @@ -1,4 +1,4 @@ -use std::time::Duration; +use std::{collections::HashSet, time::Duration}; use client_api::entity::{QueryCollab, QueryCollabParams}; use client_api_test::{ @@ -146,40 +146,49 @@ async fn move_page_to_trash() { .into_iter() .find(|v| v.name == "General") .unwrap(); - let view_id_to_be_deleted = general_space.children[0].view_id.clone(); + let view_ids_to_be_deleted = [ + general_space.children[0].view_id.clone(), + general_space.children[1].view_id.clone(), + ]; app_client.open_workspace_collab(&workspace_id).await; app_client .wait_object_sync_complete(&workspace_id) .await .unwrap(); - web_client - .api_client - .move_workspace_page_view_to_trash( - Uuid::parse_str(&workspace_id).unwrap(), - view_id_to_be_deleted.clone(), - ) - .await - .unwrap(); + for view_id in view_ids_to_be_deleted.iter() { + app_client + .api_client + .move_workspace_page_view_to_trash(Uuid::parse_str(&workspace_id).unwrap(), view_id.clone()) + .await + .unwrap(); + } let folder = get_latest_folder(&app_client, &workspace_id).await; - assert!(folder + let views_in_trash_for_app = folder .get_my_trash_sections() .iter() - .any(|v| v.id == view_id_to_be_deleted.clone())); - let view_found = web_client + .map(|v| v.id.clone()) + .collect::>(); + for view_id in view_ids_to_be_deleted.iter() { + assert!(views_in_trash_for_app.contains(view_id)); + } + let views_in_trash_for_web = web_client .api_client .get_workspace_trash(&workspace_id) .await .unwrap() .views .iter() - .any(|v| v.view.view_id == view_id_to_be_deleted.clone()); - assert!(view_found); + .map(|v| v.view.view_id.clone()) + .collect::>(); + for view_id in view_ids_to_be_deleted.iter() { + assert!(views_in_trash_for_web.contains(view_id)); + } web_client .api_client .restore_workspace_page_view_from_trash( Uuid::parse_str(&workspace_id).unwrap(), - &view_id_to_be_deleted, + &view_ids_to_be_deleted[0], ) .await .unwrap(); @@ -187,7 +196,7 @@ async fn move_page_to_trash() { assert!(!folder .get_my_trash_sections() .iter() - .any(|v| v.id == view_id_to_be_deleted.clone())); + .any(|v| v.id == view_ids_to_be_deleted[0])); let view_found = web_client .api_client .get_workspace_trash(&workspace_id) @@ -195,7 +204,26 @@ async fn move_page_to_trash() { .unwrap() .views .iter() - .any(|v| v.view.view_id == view_id_to_be_deleted.clone()); + .any(|v| v.view.view_id == view_ids_to_be_deleted[0]); + assert!(!view_found); + web_client + .api_client + .restore_all_workspace_page_views_from_trash(Uuid::parse_str(&workspace_id).unwrap()) + .await + .unwrap(); + let folder = get_latest_folder(&app_client, &workspace_id).await; + assert!(!folder + .get_my_trash_sections() + .iter() + .any(|v| v.id == view_ids_to_be_deleted[1])); + let view_found = web_client + .api_client + .get_workspace_trash(&workspace_id) + .await + .unwrap() + .views + .iter() + .any(|v| v.view.view_id == view_ids_to_be_deleted[1]); assert!(!view_found); } diff --git a/tests/workspace/workspace_crud.rs b/tests/workspace/workspace_crud.rs index 5f484ae7..76e01385 100644 --- a/tests/workspace/workspace_crud.rs +++ b/tests/workspace/workspace_crud.rs @@ -1,9 +1,42 @@ use client_api_test::generate_unique_registered_user_client; use collab_entity::CollabType; use database_entity::dto::QueryCollabParams; +use shared_entity::dto::workspace_dto::AFDatabaseField; use shared_entity::dto::workspace_dto::CreateWorkspaceParam; use shared_entity::dto::workspace_dto::PatchWorkspaceParam; +#[tokio::test] +async fn workspace_list_database() { + let (c, _user) = generate_unique_registered_user_client().await; + let workspace_id = c.get_workspaces().await.unwrap()[0].workspace_id; + let dbs = c.list_databases(&workspace_id.to_string()).await.unwrap(); + assert_eq!(dbs.len(), 1); + + let db = &dbs[0]; + + assert_eq!(db.name, ""); + assert!(db.fields.contains(&AFDatabaseField { + name: "Last modified".to_string(), + field_type: "LastEditedTime".to_string(), + })); + assert!(db.fields.contains(&AFDatabaseField { + name: "Multiselect".to_string(), + field_type: "MultiSelect".to_string(), + })); + assert!(db.fields.contains(&AFDatabaseField { + name: "Tasks".to_string(), + field_type: "Checklist".to_string(), + })); + assert!(db.fields.contains(&AFDatabaseField { + name: "Status".to_string(), + field_type: "SingleSelect".to_string(), + })); + assert!(db.fields.contains(&AFDatabaseField { + name: "Description".to_string(), + field_type: "RichText".to_string(), + })); +} + #[tokio::test] async fn add_and_delete_workspace_for_user() { let (c, _user) = generate_unique_registered_user_client().await;