diff --git a/src/inbound/renderer/admin_ui/mod.rs b/src/inbound/renderer/admin_ui/mod.rs index e004c7b..8b6997d 100644 --- a/src/inbound/renderer/admin_ui/mod.rs +++ b/src/inbound/renderer/admin_ui/mod.rs @@ -1,5 +1,7 @@ use dioxus::prelude::*; +mod server; + #[derive(Clone, Routable, Debug, PartialEq, serde::Serialize, serde::Deserialize)] #[rustfmt::skip] enum Route { @@ -17,6 +19,31 @@ pub fn App() -> Element { fn Home() -> Element { rsx! { h2 { "Hello!" } + SuspenseBoundary { fallback: |_context: SuspenseContext| rsx! { "Loading sites..." }, SiteList {} } + } +} + +fn SiteList() -> Element { + let sites = use_server_future(server::site_list)?.suspend()?; + + rsx! { + h3 { "Sites" } + match &*sites.read() { + Ok(sites) => { + rsx! { + ul { + for site in sites { + li { "{site.domain} - {site.title}" } + } + } + } + } + Err(err) => { + rsx! { + h1 { "Error: {err}" } + } + } + } } } @@ -28,3 +55,50 @@ fn AdminLayout() -> Element { } } } + +#[cfg(test)] +mod tests { + use crate::outbound::{ + repository::site::SiteMetadata, + services::site::{MockSiteService, SiteServiceProvider}, + }; + + use super::*; + + #[test] + fn site_list_shows_sites() { + let mut app = VirtualDom::new(|| { + rsx! { + SiteList {} + } + }); + + let mut mock_service = MockSiteService::new(); + mock_service + .expect_list_sites() + .times(1) + .returning(move || { + Box::pin(async { + Ok(vec![ + SiteMetadata { + title: "My test website".to_string(), + domain: "test.com".to_string(), + }, + SiteMetadata { + title: "Other site".to_string(), + domain: "other.com".to_string(), + }, + ]) + }) + }); + + server_context().insert(SiteServiceProvider::with(mock_service)); + + app.rebuild_in_place(); + let elem_str = dioxus::ssr::render(&app); + assert!(elem_str.contains("My test website")); + assert!(elem_str.contains("test.com")); + assert!(elem_str.contains("Other site")); + assert!(elem_str.contains("other.com")); + } +} diff --git a/src/inbound/renderer/admin_ui/server.rs b/src/inbound/renderer/admin_ui/server.rs new file mode 100644 index 0000000..10739ce --- /dev/null +++ b/src/inbound/renderer/admin_ui/server.rs @@ -0,0 +1,20 @@ +use dioxus::prelude::*; + +use crate::domain::entities::site::SiteInfo; + +#[server] +pub async fn site_list() -> Result<Vec<SiteInfo>, ServerFnError> { + use crate::outbound::services::site::SiteServiceProvider; + let FromContext(SiteServiceProvider { service }) = extract().await?; + + let sites = service.list_sites().await?; + + Ok(sites + .iter() + .map(|site| SiteInfo { + domain: site.domain.clone(), + title: site.title.clone(), + pages: vec![], + }) + .collect()) +} diff --git a/src/outbound/repository/adapters/memory/mod.rs b/src/outbound/repository/adapters/memory/mod.rs new file mode 100644 index 0000000..30cfa95 --- /dev/null +++ b/src/outbound/repository/adapters/memory/mod.rs @@ -0,0 +1,139 @@ +use std::{collections::HashMap, sync::Arc}; + +use tokio::sync::Mutex; + +use crate::{ + domain::entities::site::{Page, Post}, + outbound::repository::site::SiteMetadata, +}; + +mod site; + +struct InMemoryStoreData { + sites: HashMap<String, SiteMetadata>, + pages: HashMap<(String, String), Page>, + posts: HashMap<(String, String), HashMap<String, Post>>, +} + +#[derive(Clone)] +pub struct InMemoryStore { + data: Arc<Mutex<InMemoryStoreData>>, +} + +impl InMemoryStore { + pub fn new() -> InMemoryStore { + InMemoryStore { + data: Arc::new(Mutex::new(InMemoryStoreData { + sites: HashMap::new(), + pages: HashMap::new(), + posts: HashMap::new(), + })), + } + } + + pub async fn add_post(&self, site: &str, collection_id: &str, post_id: &str, post: Post) { + self.data + .lock() + .await + .posts + .entry((site.to_string(), collection_id.to_string())) + .or_insert_with(HashMap::new) + .insert(post_id.to_string(), post); + } + + #[cfg(test)] + pub async fn with_test_data() -> InMemoryStore { + use crate::{ + domain::entities::site::{Block, PageContent, PageInfo, Post}, + outbound::repository::site::SiteRepository, + }; + + let store = InMemoryStore::new(); + + store + .create_site(SiteMetadata { + domain: "example.com".to_string(), + title: "Test site".to_string(), + }) + .await + .unwrap(); + + store + .create_site(SiteMetadata { + domain: "other.com".to_string(), + title: "Other site".to_string(), + }) + .await + .unwrap(); + + store + .set_page( + "example.com", + Page { + info: PageInfo { + title: "Home".to_string(), + name: "/".to_string(), + order: 0, + }, + content: PageContent::Single { + content: Post { + blocks: vec![Block::Text { + text: "Hello, world!".to_string(), + }], + }, + }, + }, + ) + .await + .unwrap(); + + store + .set_page( + "example.com", + Page { + info: PageInfo { + title: "About".to_string(), + name: "about".to_string(), + order: 10, + }, + content: PageContent::Single { + content: Post { + blocks: vec![Block::Text { + text: "This is the about page.".to_string(), + }], + }, + }, + }, + ) + .await + .unwrap(); + + store + .add_post( + "example.com", + "home_posts", + "post_id", + Post { + blocks: vec![Block::Text { + text: "Hello, world!".to_string(), + }], + }, + ) + .await; + + store + .add_post( + "example.com", + "home_posts", + "post_id2", + Post { + blocks: vec![Block::Text { + text: "Hello again!".to_string(), + }], + }, + ) + .await; + + store + } +} diff --git a/src/outbound/repository/adapters/memory.rs b/src/outbound/repository/adapters/memory/site.rs similarity index 76% rename from src/outbound/repository/adapters/memory.rs rename to src/outbound/repository/adapters/memory/site.rs index 8d89a57..9706612 100644 --- a/src/outbound/repository/adapters/memory.rs +++ b/src/outbound/repository/adapters/memory/site.rs @@ -1,146 +1,28 @@ -use std::{collections::HashMap, sync::Arc}; - use async_trait::async_trait; -use tokio::sync::Mutex; use crate::{ domain::entities::{ cursor::{CursorOptions, Paginated}, site::{Page, PageInfo, Post}, }, - outbound::repository::{ - self, - site::{Result, SiteMetadata, SiteRepository}, - }, + outbound::repository::site::{Error, Result, SiteMetadata, SiteRepository}, }; -struct InMemoryStoreData { - sites: HashMap<String, SiteMetadata>, - pages: HashMap<(String, String), Page>, - posts: HashMap<(String, String), HashMap<String, Post>>, -} - -#[derive(Clone)] -pub struct InMemoryStore { - data: Arc<Mutex<InMemoryStoreData>>, -} - -impl InMemoryStore { - pub fn new() -> InMemoryStore { - InMemoryStore { - data: Arc::new(Mutex::new(InMemoryStoreData { - sites: HashMap::new(), - pages: HashMap::new(), - posts: HashMap::new(), - })), - } - } - - pub async fn add_post(&self, site: &str, collection_id: &str, post_id: &str, post: Post) { - self.data - .lock() - .await - .posts - .entry((site.to_string(), collection_id.to_string())) - .or_insert_with(HashMap::new) - .insert(post_id.to_string(), post); - } - - #[cfg(test)] - pub async fn with_test_data() -> InMemoryStore { - use crate::domain::entities::site::{Block, PageContent, Post}; - - let store = InMemoryStore::new(); - - store - .create_site(SiteMetadata { - domain: "example.com".to_string(), - title: "Test site".to_string(), - }) - .await - .unwrap(); - - store - .set_page( - "example.com", - Page { - info: PageInfo { - title: "Home".to_string(), - name: "/".to_string(), - order: 0, - }, - content: PageContent::Single { - content: Post { - blocks: vec![Block::Text { - text: "Hello, world!".to_string(), - }], - }, - }, - }, - ) - .await - .unwrap(); - - store - .set_page( - "example.com", - Page { - info: PageInfo { - title: "About".to_string(), - name: "about".to_string(), - order: 10, - }, - content: PageContent::Single { - content: Post { - blocks: vec![Block::Text { - text: "This is the about page.".to_string(), - }], - }, - }, - }, - ) - .await - .unwrap(); - - store - .add_post( - "example.com", - "home_posts", - "post_id", - Post { - blocks: vec![Block::Text { - text: "Hello, world!".to_string(), - }], - }, - ) - .await; - - store - .add_post( - "example.com", - "home_posts", - "post_id2", - Post { - blocks: vec![Block::Text { - text: "Hello again!".to_string(), - }], - }, - ) - .await; - - store - } -} +use super::InMemoryStore; #[async_trait] impl SiteRepository for InMemoryStore { + async fn list_sites(&self) -> Result<Vec<SiteMetadata>> { + Ok(self.data.lock().await.sites.values().cloned().collect()) + } + async fn get_site_by_domain(&self, domain: &str) -> Result<SiteMetadata> { self.data .lock() .await .sites .get(domain) - .ok_or(repository::site::Error::NotFound) + .ok_or(Error::NotFound) .cloned() } @@ -163,7 +45,7 @@ impl SiteRepository for InMemoryStore { async fn create_site(&self, site: SiteMetadata) -> Result<()> { // Check for existing site if self.data.lock().await.sites.contains_key(&site.domain) { - return Err(repository::site::Error::Conflict); + return Err(Error::Conflict); } self.data @@ -181,7 +63,7 @@ impl SiteRepository for InMemoryStore { .await .sites .remove(domain) - .ok_or(repository::site::Error::NotFound) + .ok_or(Error::NotFound) .map(|_| ()) } @@ -191,7 +73,7 @@ impl SiteRepository for InMemoryStore { .await .pages .get(&(domain.to_string(), page.to_string())) - .ok_or(repository::site::Error::NotFound) + .ok_or(Error::NotFound) .cloned() } @@ -214,7 +96,7 @@ impl SiteRepository for InMemoryStore { .remove(&(domain.to_string(), page.to_string())); if deleted.is_none() { - return Err(repository::site::Error::NotFound); + return Err(Error::NotFound); } Ok(()) @@ -228,7 +110,7 @@ impl SiteRepository for InMemoryStore { .get(&(domain.to_string(), collection_id.to_string())) .and_then(|posts| posts.get(post_id)) .cloned() - .ok_or(repository::site::Error::NotFound) + .ok_or(Error::NotFound) } async fn get_posts_for_collection( @@ -243,7 +125,7 @@ impl SiteRepository for InMemoryStore { .await .posts .get(&(domain.to_string(), collection_id.to_string())) - .ok_or(repository::site::Error::NotFound) + .ok_or(Error::NotFound) .cloned()?; // Sort by post_id @@ -300,6 +182,22 @@ mod tests { use super::InMemoryStore; + #[tokio::test] + async fn list_sites_works() { + let store = InMemoryStore::with_test_data().await; + let mut sites = store.list_sites().await.unwrap(); + + assert_eq!(sites.len(), 2); + + // sort by name + sites.sort_by_key(|site| site.domain.clone()); + + assert_eq!(sites[0].domain, "example.com"); + assert_eq!(sites[0].title, "Test site"); + assert_eq!(sites[1].domain, "other.com"); + assert_eq!(sites[1].title, "Other site"); + } + #[tokio::test] async fn get_site_by_domain_works() { let store = InMemoryStore::with_test_data().await; diff --git a/src/outbound/repository/site.rs b/src/outbound/repository/site.rs index ebad384..d265536 100644 --- a/src/outbound/repository/site.rs +++ b/src/outbound/repository/site.rs @@ -13,6 +13,7 @@ pub struct SiteMetadata { #[async_trait::async_trait] pub trait SiteRepository: Send + Sync + 'static { + async fn list_sites(&self) -> Result<Vec<SiteMetadata>>; async fn get_site_by_domain(&self, domain: &str) -> Result<SiteMetadata>; async fn create_site(&self, site: SiteMetadata) -> Result<()>; async fn delete_site(&self, domain: &str) -> Result<()>; diff --git a/src/outbound/services/site.rs b/src/outbound/services/site.rs index 3214833..fa26621 100644 --- a/src/outbound/services/site.rs +++ b/src/outbound/services/site.rs @@ -29,6 +29,7 @@ impl SiteServiceProvider { #[async_trait::async_trait] #[mockall::automock] pub trait SiteService: Send + Sync + 'static { + async fn list_sites(&self) -> Result<Vec<SiteMetadata>>; async fn get_site(&self, domain: &str) -> Result<SiteInfo>; async fn create_site(&self, site: SiteMetadata) -> Result<()>; async fn delete_site(&self, domain: &str) -> Result<()>; @@ -58,6 +59,12 @@ impl SiteServiceImpl { #[async_trait::async_trait] impl SiteService for SiteServiceImpl { + async fn list_sites(&self) -> Result<Vec<SiteMetadata>> { + let sites = self.site_repository.list_sites().await?; + + Ok(sites) + } + async fn get_site(&self, domain: &str) -> Result<SiteInfo> { let info = self.site_repository.get_site_by_domain(domain).await?; let pages = self @@ -173,6 +180,19 @@ mod tests { assert_eq!(info.pages.len(), 2); } + #[tokio::test] + async fn gets_site_list() { + let service = SiteServiceImpl::new(InMemoryStore::with_test_data().await); + let mut sites = service.list_sites().await.unwrap(); + assert_eq!(sites.len(), 2); + + sites.sort_by_key(|site_info| site_info.domain.clone()); + assert_eq!(sites[0].domain, "example.com"); + assert_eq!(sites[0].title, "Test site"); + assert_eq!(sites[1].domain, "other.com"); + assert_eq!(sites[1].title, "Other site"); + } + #[tokio::test] async fn gets_nonexistent_site() { let service = SiteServiceImpl::new(InMemoryStore::with_test_data().await);