fix: Show project documents in workspace #26

Manually merged
day01 merged 1 commits from feat/0.6-project-docs-ui into develop 2026-08-30 20:16:28 +00:00
2 changed files with 58 additions and 6 deletions
+30
View File
@@ -1,299 +1,329 @@
use cynic::{MutationBuilder, QueryBuilder};

use crate::graphql::collab_schema as schema;
use crate::graphql::typed_collab_graphql;

#[derive(Clone, cynic::Enum, PartialEq, Eq)]
#[cynic(schema_path = "collab-schema.graphql")]
pub enum OwnerKind {
User,
Organization,
}

#[derive(Clone, cynic::Enum, PartialEq, Eq)]
#[cynic(schema_path = "collab-schema.graphql")]
pub enum RepositoryRole {
Source,
Infra,
}

#[derive(Clone, cynic::Enum, PartialEq, Eq)]
#[cynic(schema_path = "collab-schema.graphql")]
pub enum ProjectKind {
Continuous,
Finite,
}

#[derive(Clone, cynic::Enum, PartialEq, Eq)]
#[cynic(schema_path = "collab-schema.graphql")]
pub enum IssuePriority {
None,
Low,
Normal,
High,
Urgent,
}

#[derive(Clone, cynic::QueryFragment)]
#[cynic(schema_path = "collab-schema.graphql")]
pub struct Workspace {
pub id: cynic::Id,
pub name: String,
pub slug: String,
pub default_environment: String,
}

#[derive(Clone, cynic::QueryFragment)]
#[cynic(schema_path = "collab-schema.graphql")]
pub struct CheckoutRepository {
pub owner: String,
pub name: String,
pub role: RepositoryRole,
pub branch: String,
}

#[derive(Clone, cynic::QueryFragment)]
#[cynic(schema_path = "collab-schema.graphql")]
pub struct Checkout {
pub repositories: Vec<CheckoutRepository>,
}

#[derive(Clone, cynic::QueryFragment)]
#[cynic(schema_path = "collab-schema.graphql")]
pub struct Environment {
pub name: String,
pub default_source_branch: String,
pub infra_branch: Option<String>,
}

#[derive(Clone, cynic::QueryFragment)]
#[cynic(schema_path = "collab-schema.graphql")]
pub struct Project {
pub id: cynic::Id,
pub key: String,
pub name: String,
pub slug: String,
pub description: String,
pub lifecycle: String,
}

#[derive(Clone, cynic::QueryFragment)]
#[cynic(schema_path = "collab-schema.graphql")]
pub struct Document {
pub path: String,
pub revision_id: cynic::Id,
pub content: String,
}

#[derive(Clone, cynic::QueryFragment)]
#[cynic(schema_path = "collab-schema.graphql")]
pub struct Issue {
pub number: i32,
pub title: String,
}

#[derive(Clone, cynic::QueryFragment)]
#[cynic(schema_path = "collab-schema.graphql")]
pub struct Version {
pub name: String,
pub slug: String,
pub state: String,
}

#[derive(cynic::QueryFragment)]
#[cynic(schema_path = "collab-schema.graphql", graphql_type = "Query")]
struct WorkspacesResponse {
workspaces: Vec<Workspace>,
}

pub async fn workspaces() -> Result<Vec<Workspace>, String> {
let response: WorkspacesResponse = typed_collab_graphql(QueryBuilder::build(())).await?;
Ok(response.workspaces)
}

#[derive(cynic::QueryVariables)]
struct WorkspaceVariables {
id: cynic::Id,
workspace_id: cynic::Id,
}

#[derive(cynic::QueryFragment)]
#[cynic(
schema_path = "collab-schema.graphql",
graphql_type = "Query",
variables = "WorkspaceVariables"
)]
struct WorkspaceResponse {
#[arguments(id: $id)]
workspace: Workspace,
#[arguments(workspaceId: $workspace_id)]
checkout: Checkout,
#[arguments(workspaceId: $workspace_id)]
environments: Vec<Environment>,
#[arguments(workspaceId: $workspace_id)]
projects: Vec<Project>,
#[arguments(workspaceId: $workspace_id)]
documents: Vec<Document>,
}

pub async fn workspace(
id: cynic::Id,
) -> Result<
(
Workspace,
Vec<CheckoutRepository>,
Vec<Environment>,
Vec<Project>,
Vec<Document>,
),
String,
> {
let response: WorkspaceResponse =
typed_collab_graphql(QueryBuilder::build(WorkspaceVariables {
id: id.clone(),
workspace_id: id,
}))
.await?;
Ok((
response.workspace,
response.checkout.repositories,
response.environments,
response.projects,
response.documents,
))
}

#[derive(cynic::QueryVariables)]
struct IssuesVariables {
project_id: cynic::Id,
}

#[derive(cynic::QueryFragment)]
#[cynic(
schema_path = "collab-schema.graphql",
graphql_type = "Query",
variables = "IssuesVariables"
)]
struct IssuesResponse {
#[arguments(projectId: $project_id)]
issues: Vec<Issue>,
}

pub async fn issues(project_id: cynic::Id) -> Result<Vec<Issue>, String> {
let response: IssuesResponse =
typed_collab_graphql(QueryBuilder::build(IssuesVariables { project_id })).await?;
Ok(response.issues)
}

#[derive(cynic::QueryVariables)]
struct VersionsVariables {
project_id: cynic::Id,
}

#[derive(cynic::QueryFragment)]
#[cynic(
schema_path = "collab-schema.graphql",
graphql_type = "Query",
variables = "VersionsVariables"
)]
struct VersionsResponse {
#[arguments(projectId: $project_id)]
versions: Vec<Version>,
}

pub async fn versions(project_id: cynic::Id) -> Result<Vec<Version>, String> {
let response: VersionsResponse =
typed_collab_graphql(QueryBuilder::build(VersionsVariables { project_id })).await?;
Ok(response.versions)
}

#[derive(cynic::InputObject)]
#[cynic(schema_path = "collab-schema.graphql")]
struct CreateWorkspaceInput {
owner_kind: OwnerKind,
owner_id: cynic::Id,
name: String,
slug: String,
default_environment: String,
default_source_branch: String,
}

#[derive(cynic::QueryVariables)]
struct CreateWorkspaceVariables {
input: CreateWorkspaceInput,
}

#[derive(cynic::QueryFragment)]
#[cynic(
schema_path = "collab-schema.graphql",
graphql_type = "Mutation",
variables = "CreateWorkspaceVariables"
)]
struct CreateWorkspaceResponse {
#[arguments(input: $input)]
create_workspace: Workspace,
}

pub async fn create_workspace(owner_id: &str, name: &str, slug: &str) -> Result<Workspace, String> {
let response: CreateWorkspaceResponse =
typed_collab_graphql(MutationBuilder::build(CreateWorkspaceVariables {
input: CreateWorkspaceInput {
owner_kind: OwnerKind::User,
owner_id: cynic::Id::new(owner_id),
name: name.to_owned(),
slug: slug.to_owned(),
default_environment: "development".to_owned(),
default_source_branch: "main".to_owned(),
},
}))
.await?;
Ok(response.create_workspace)
}

#[derive(cynic::InputObject)]
#[cynic(schema_path = "collab-schema.graphql")]
struct CreateProjectInput {
workspace_id: cynic::Id,
key: String,
name: String,
slug: String,
description: String,
kind: ProjectKind,
}

#[derive(cynic::QueryVariables)]
struct CreateProjectVariables {
input: CreateProjectInput,
}

#[derive(cynic::QueryFragment)]
#[cynic(
schema_path = "collab-schema.graphql",
graphql_type = "Mutation",
variables = "CreateProjectVariables"
)]
struct CreateProjectResponse {
#[arguments(input: $input)]
create_project: Project,
}

pub async fn create_project(
workspace_id: &str,
key: &str,
name: &str,
slug: &str,
) -> Result<Project, String> {
let response: CreateProjectResponse =
typed_collab_graphql(MutationBuilder::build(CreateProjectVariables {
input: CreateProjectInput {
workspace_id: cynic::Id::new(workspace_id),
key: key.to_owned(),
name: name.to_owned(),
slug: slug.to_owned(),
description: String::new(),
kind: ProjectKind::Continuous,
},
}))
.await?;
Ok(response.create_project)
}
use cynic::{MutationBuilder, QueryBuilder};

use crate::graphql::collab_schema as schema;
use crate::graphql::typed_collab_graphql;

#[derive(Clone, cynic::Enum, PartialEq, Eq)]
#[cynic(schema_path = "collab-schema.graphql")]
pub enum OwnerKind {
User,
Organization,
}

#[derive(Clone, cynic::Enum, PartialEq, Eq)]
#[cynic(schema_path = "collab-schema.graphql")]
pub enum RepositoryRole {
Source,
Infra,
}

#[derive(Clone, cynic::Enum, PartialEq, Eq)]
#[cynic(schema_path = "collab-schema.graphql")]
pub enum ProjectKind {
Continuous,
Finite,
}

#[derive(Clone, cynic::Enum, PartialEq, Eq)]
#[cynic(schema_path = "collab-schema.graphql")]
pub enum IssuePriority {
None,
Low,
Normal,
High,
Urgent,
}

#[derive(Clone, cynic::QueryFragment)]
#[cynic(schema_path = "collab-schema.graphql")]
pub struct Workspace {
pub id: cynic::Id,
pub name: String,
pub slug: String,
pub default_environment: String,
}

#[derive(Clone, cynic::QueryFragment)]
#[cynic(schema_path = "collab-schema.graphql")]
pub struct CheckoutRepository {
pub owner: String,
pub name: String,
pub role: RepositoryRole,
pub branch: String,
}

#[derive(Clone, cynic::QueryFragment)]
#[cynic(schema_path = "collab-schema.graphql")]
pub struct Checkout {
pub repositories: Vec<CheckoutRepository>,
}

#[derive(Clone, cynic::QueryFragment)]
#[cynic(schema_path = "collab-schema.graphql")]
pub struct Environment {
pub name: String,
pub default_source_branch: String,
pub infra_branch: Option<String>,
}

#[derive(Clone, cynic::QueryFragment)]
#[cynic(schema_path = "collab-schema.graphql")]
pub struct Project {
pub id: cynic::Id,
pub key: String,
pub name: String,
pub slug: String,
pub description: String,
pub lifecycle: String,
}

#[derive(Clone, cynic::QueryFragment)]
#[cynic(schema_path = "collab-schema.graphql")]
pub struct Document {
pub path: String,
pub revision_id: cynic::Id,
pub content: String,
}

#[derive(Clone, cynic::QueryFragment)]
#[cynic(schema_path = "collab-schema.graphql")]
pub struct Issue {
pub number: i32,
pub title: String,
}

#[derive(Clone, cynic::QueryFragment)]
#[cynic(schema_path = "collab-schema.graphql")]
pub struct Version {
pub name: String,
pub slug: String,
pub state: String,
}

#[derive(cynic::QueryFragment)]
#[cynic(schema_path = "collab-schema.graphql", graphql_type = "Query")]
struct WorkspacesResponse {
workspaces: Vec<Workspace>,
}

pub async fn workspaces() -> Result<Vec<Workspace>, String> {
let response: WorkspacesResponse = typed_collab_graphql(QueryBuilder::build(())).await?;
Ok(response.workspaces)
}

#[derive(cynic::QueryVariables)]
struct WorkspaceVariables {
id: cynic::Id,
workspace_id: cynic::Id,
}

#[derive(cynic::QueryFragment)]
#[cynic(
schema_path = "collab-schema.graphql",
graphql_type = "Query",
variables = "WorkspaceVariables"
)]
struct WorkspaceResponse {
#[arguments(id: $id)]
workspace: Workspace,
#[arguments(workspaceId: $workspace_id)]
checkout: Checkout,
#[arguments(workspaceId: $workspace_id)]
environments: Vec<Environment>,
#[arguments(workspaceId: $workspace_id)]
projects: Vec<Project>,
#[arguments(workspaceId: $workspace_id)]
documents: Vec<Document>,
}

pub async fn workspace(
id: cynic::Id,
) -> Result<
(
Workspace,
Vec<CheckoutRepository>,
Vec<Environment>,
Vec<Project>,
Vec<Document>,
),
String,
> {
let response: WorkspaceResponse =
typed_collab_graphql(QueryBuilder::build(WorkspaceVariables {
id: id.clone(),
workspace_id: id,
}))
.await?;
Ok((
response.workspace,
response.checkout.repositories,
response.environments,
response.projects,
response.documents,
))
}

#[derive(cynic::QueryVariables)]
struct DocumentsVariables {
workspace_id: cynic::Id,
project_id: Option<cynic::Id>,
}

#[derive(cynic::QueryFragment)]
#[cynic(
schema_path = "collab-schema.graphql",
graphql_type = "Query",
variables = "DocumentsVariables"
)]
struct DocumentsResponse {
#[arguments(workspaceId: $workspace_id, projectId: $project_id)]
documents: Vec<Document>,
}

pub async fn project_documents(
workspace_id: cynic::Id,
project_id: cynic::Id,
) -> Result<Vec<Document>, String> {
let response: DocumentsResponse =
typed_collab_graphql(QueryBuilder::build(DocumentsVariables {
workspace_id,
project_id: Some(project_id),
}))
.await?;
Ok(response.documents)
}

#[derive(cynic::QueryVariables)]
struct IssuesVariables {
project_id: cynic::Id,
}

#[derive(cynic::QueryFragment)]
#[cynic(
schema_path = "collab-schema.graphql",
graphql_type = "Query",
variables = "IssuesVariables"
)]
struct IssuesResponse {
#[arguments(projectId: $project_id)]
issues: Vec<Issue>,
}

pub async fn issues(project_id: cynic::Id) -> Result<Vec<Issue>, String> {
let response: IssuesResponse =
typed_collab_graphql(QueryBuilder::build(IssuesVariables { project_id })).await?;
Ok(response.issues)
}

#[derive(cynic::QueryVariables)]
struct VersionsVariables {
project_id: cynic::Id,
}

#[derive(cynic::QueryFragment)]
#[cynic(
schema_path = "collab-schema.graphql",
graphql_type = "Query",
variables = "VersionsVariables"
)]
struct VersionsResponse {
#[arguments(projectId: $project_id)]
versions: Vec<Version>,
}

pub async fn versions(project_id: cynic::Id) -> Result<Vec<Version>, String> {
let response: VersionsResponse =
typed_collab_graphql(QueryBuilder::build(VersionsVariables { project_id })).await?;
Ok(response.versions)
}

#[derive(cynic::InputObject)]
#[cynic(schema_path = "collab-schema.graphql")]
struct CreateWorkspaceInput {
owner_kind: OwnerKind,
owner_id: cynic::Id,
name: String,
slug: String,
default_environment: String,
default_source_branch: String,
}

#[derive(cynic::QueryVariables)]
struct CreateWorkspaceVariables {
input: CreateWorkspaceInput,
}

#[derive(cynic::QueryFragment)]
#[cynic(
schema_path = "collab-schema.graphql",
graphql_type = "Mutation",
variables = "CreateWorkspaceVariables"
)]
struct CreateWorkspaceResponse {
#[arguments(input: $input)]
create_workspace: Workspace,
}

pub async fn create_workspace(owner_id: &str, name: &str, slug: &str) -> Result<Workspace, String> {
let response: CreateWorkspaceResponse =
typed_collab_graphql(MutationBuilder::build(CreateWorkspaceVariables {
input: CreateWorkspaceInput {
owner_kind: OwnerKind::User,
owner_id: cynic::Id::new(owner_id),
name: name.to_owned(),
slug: slug.to_owned(),
default_environment: "development".to_owned(),
default_source_branch: "main".to_owned(),
},
}))
.await?;
Ok(response.create_workspace)
}

#[derive(cynic::InputObject)]
#[cynic(schema_path = "collab-schema.graphql")]
struct CreateProjectInput {
workspace_id: cynic::Id,
key: String,
name: String,
slug: String,
description: String,
kind: ProjectKind,
}

#[derive(cynic::QueryVariables)]
struct CreateProjectVariables {
input: CreateProjectInput,
}

#[derive(cynic::QueryFragment)]
#[cynic(
schema_path = "collab-schema.graphql",
graphql_type = "Mutation",
variables = "CreateProjectVariables"
)]
struct CreateProjectResponse {
#[arguments(input: $input)]
create_project: Project,
}

pub async fn create_project(
workspace_id: &str,
key: &str,
name: &str,
slug: &str,
) -> Result<Project, String> {
let response: CreateProjectResponse =
typed_collab_graphql(MutationBuilder::build(CreateProjectVariables {
input: CreateProjectInput {
workspace_id: cynic::Id::new(workspace_id),
key: key.to_owned(),
name: name.to_owned(),
slug: slug.to_owned(),
description: String::new(),
kind: ProjectKind::Continuous,
},
}))
.await?;
Ok(response.create_project)
}
+28 -6
View File
@@ -1,105 +1,127 @@
use leptos::prelude::*;
use leptos_router::components::A;
use leptos_router::hooks::use_params_map;

use crate::collab::{issues, versions, workspace, Issue, Project, Version};
use crate::components::nav::Nav;

use super::workspace_forms::{
AttachRepositoryForm, DocumentForm, EnvironmentForm, IssueForm, ProjectForm, VersionForm,
};

#[derive(Clone)]
struct WorkspaceData {
workspace: crate::collab::Workspace,
repositories: Vec<crate::collab::CheckoutRepository>,
environments: Vec<crate::collab::Environment>,
projects: Vec<(Project, Vec<Issue>, Vec<Version>)>,
documents: Vec<crate::collab::Document>,
}

async fn load_workspace(id: &str) -> Result<WorkspaceData, String> {
let (workspace, repositories, environments, projects, documents) =
workspace(cynic::Id::new(id)).await?;
let mut projects_with_issues = Vec::with_capacity(projects.len());
for project in projects {
let project_issues = issues(project.id.clone()).await?;
let project_versions = versions(project.id.clone()).await?;
projects_with_issues.push((project, project_issues, project_versions));
}
Ok(WorkspaceData {
workspace,
repositories,
environments,
projects: projects_with_issues,
documents,
})
}

#[component]
pub fn WorkspacePage() -> impl IntoView {
let params = use_params_map();
let refresh = RwSignal::new(0_u64);
let data = LocalResource::new(move || {
refresh.get();
let id = params.read().get("id").unwrap_or_default();
async move { load_workspace(&id).await }
});

view! {
<div class="flex min-h-screen flex-col bg-paper text-ink">
<Nav>
<A href="/workspaces" attr:class="text-sm font-medium hover:text-primary">"Workspaces"</A>
<A href="/account" attr:class="text-sm font-medium hover:text-primary">"Account"</A>
</Nav>
<main class="mx-auto w-full max-w-6xl flex-1 px-6 py-12">
<Suspense fallback=|| view! { <p class="text-ink/60">"Loading workspace…"</p> }>
{move || data.get().map(|result| match &*result {
Ok(data) => {
let workspace = data.workspace.clone();
let workspace_id = workspace.id.inner().to_owned();
let projects = data.projects.clone();
let documents = data.documents.clone();
let repositories = data.repositories.clone();
let environments = data.environments.clone();
view! {
<header class="flex flex-wrap items-end justify-between gap-4 border-b border-ink/10 pb-8">
<div><p class="font-mono text-sm text-ink/50">{workspace.slug}</p><h1 class="mt-1 text-3xl font-semibold">{workspace.name}</h1></div>
<span class="rounded-full bg-tertiary/20 px-3 py-1 text-xs font-medium">{workspace.default_environment}</span>
</header>
<div class="mt-8 grid gap-8 lg:grid-cols-[1fr_22rem]">
<div class="space-y-10">
<section>
<div class="flex items-center justify-between"><h2 class="text-xl font-semibold">"Projects"</h2><span class="text-sm text-ink/50">{projects.len()}</span></div>
<ul class="mt-4 space-y-4">{projects.clone().into_iter().map(|(project, issues, versions)| view! {
<li class="rounded-xl border border-ink/10 bg-white p-5">
<div class="flex items-center justify-between gap-4"><div><h3 class="font-semibold">{format!("{} · {}", project.key, project.name)}</h3><p class="mt-1 text-sm text-ink/55">{project.description}</p><p class="mt-1 font-mono text-xs text-ink/40">{project.slug}</p></div><span class="text-xs uppercase text-ink/45">{project.lifecycle}</span></div>
<ul class="mt-4 divide-y divide-ink/10 border-t border-ink/10">{issues.into_iter().map(|issue| view! { <li class="flex items-center gap-3 py-3 text-sm"><span class="font-mono text-ink/45">{format!("#{}", issue.number)}</span><span>{issue.title}</span></li> }).collect_view()}</ul>
<IssueForm project_id=project.id.inner().to_owned() refresh=refresh/>
<div class="mt-5 border-t border-ink/10 pt-4"><h4 class="text-xs font-semibold uppercase tracking-wide text-ink/45">"Versions"</h4><ul class="mt-2 flex flex-wrap gap-2">{versions.into_iter().map(|version| view! { <li class="rounded-full bg-ink/5 px-3 py-1 text-xs">{format!("{} ({}) · {}", version.name, version.slug, version.state)}</li> }).collect_view()}</ul><VersionForm project_id=project.id.inner().to_owned() refresh=refresh/></div>
</li>
}).collect_view()}</ul>
</section>
<section>
<div class="flex items-center justify-between"><h2 class="text-xl font-semibold">"Docs"</h2><span class="text-sm text-ink/50">{documents.len()}</span></div>
<ul class="mt-4 space-y-3">{documents.into_iter().map(|document| view! { <li class="rounded-xl border border-ink/10 bg-white p-5"><div class="flex items-center justify-between gap-4"><p class="font-mono text-sm font-semibold">{document.path}</p><span class="font-mono text-xs text-ink/35">{document.revision_id.inner().to_owned()}</span></div><pre class="mt-3 whitespace-pre-wrap text-sm text-ink/70">{document.content}</pre></li> }).collect_view()}</ul>
</section>
</div>
<aside class="space-y-5">
<section class="rounded-xl border border-ink/10 bg-white p-5"><div class="flex items-center justify-between"><h2 class="font-semibold">"Environments"</h2><span class="text-sm text-ink/50">{environments.len()}</span></div><ul class="mt-3 space-y-2">{environments.into_iter().map(|environment| view! { <li class="rounded border border-ink/10 px-3 py-2 text-sm"><p class="font-medium">{environment.name}</p><p class="mt-1 font-mono text-xs text-ink/45">{format!("source: {} · infra: {}", environment.default_source_branch, environment.infra_branch.unwrap_or_else(|| "not configured".to_owned()))}</p></li> }).collect_view()}</ul></section>
<EnvironmentForm workspace_id=workspace_id.clone() refresh=refresh/>
<section class="rounded-xl border border-ink/10 bg-white p-5"><div class="flex items-center justify-between"><h2 class="font-semibold">"Repositories"</h2><span class="text-sm text-ink/50">{repositories.len()}</span></div><ul class="mt-3 space-y-2">{repositories.into_iter().map(|repository| { let role = match repository.role { crate::collab::RepositoryRole::Source => "source", crate::collab::RepositoryRole::Infra => "infra" }; view! { <li class="rounded border border-ink/10 px-3 py-2 text-sm"><p class="font-medium">{format!("{}/{}", repository.owner, repository.name)}</p><p class="mt-1 font-mono text-xs text-ink/45">{format!("{} · {role}", repository.branch)}</p></li> } }).collect_view()}</ul></section>
<ProjectForm workspace_id=workspace_id.clone() refresh=refresh/>
<DocumentForm workspace_id=workspace_id.clone() refresh=refresh/>
<AttachRepositoryForm workspace_id=workspace_id refresh=refresh/>
</aside>
</div>
}.into_any()
}
Err(message) => view! { <p class="text-red-600">{message.clone()}</p> }.into_any(),
})}
</Suspense>
</main>
</div>
}
}
use leptos::prelude::*;
use leptos_router::components::A;
use leptos_router::hooks::use_params_map;

use crate::collab::{
issues, project_documents, versions, workspace, Document, Issue, Project, Version,
};
use crate::components::nav::Nav;

use super::workspace_forms::{
AttachRepositoryForm, DocumentForm, EnvironmentForm, IssueForm, ProjectForm, VersionForm,
};

#[derive(Clone)]
struct WorkspaceData {
workspace: crate::collab::Workspace,
repositories: Vec<crate::collab::CheckoutRepository>,
environments: Vec<crate::collab::Environment>,
projects: Vec<ProjectData>,
documents: Vec<Document>,
}

#[derive(Clone)]
struct ProjectData {
project: Project,
issues: Vec<Issue>,
versions: Vec<Version>,
documents: Vec<Document>,
}

async fn load_workspace(id: &str) -> Result<WorkspaceData, String> {
let (workspace, repositories, environments, projects, documents) =
workspace(cynic::Id::new(id)).await?;
let mut projects_with_issues = Vec::with_capacity(projects.len());
for project in projects {
let project_issues = issues(project.id.clone()).await?;
let project_versions = versions(project.id.clone()).await?;
let documents = project_documents(workspace.id.clone(), project.id.clone()).await?;
projects_with_issues.push(ProjectData {
project,
issues: project_issues,
versions: project_versions,
documents,
});
}
Ok(WorkspaceData {
workspace,
repositories,
environments,
projects: projects_with_issues,
documents,
})
}

#[component]
pub fn WorkspacePage() -> impl IntoView {
let params = use_params_map();
let refresh = RwSignal::new(0_u64);
let data = LocalResource::new(move || {
refresh.get();
let id = params.read().get("id").unwrap_or_default();
async move { load_workspace(&id).await }
});

view! {
<div class="flex min-h-screen flex-col bg-paper text-ink">
<Nav>
<A href="/workspaces" attr:class="text-sm font-medium hover:text-primary">"Workspaces"</A>
<A href="/account" attr:class="text-sm font-medium hover:text-primary">"Account"</A>
</Nav>
<main class="mx-auto w-full max-w-6xl flex-1 px-6 py-12">
<Suspense fallback=|| view! { <p class="text-ink/60">"Loading workspace…"</p> }>
{move || data.get().map(|result| match &*result {
Ok(data) => {
let workspace = data.workspace.clone();
let workspace_id = workspace.id.inner().to_owned();
let projects = data.projects.clone();
let documents = data.documents.clone();
let repositories = data.repositories.clone();
let environments = data.environments.clone();
view! {
<header class="flex flex-wrap items-end justify-between gap-4 border-b border-ink/10 pb-8">
<div><p class="font-mono text-sm text-ink/50">{workspace.slug}</p><h1 class="mt-1 text-3xl font-semibold">{workspace.name}</h1></div>
<span class="rounded-full bg-tertiary/20 px-3 py-1 text-xs font-medium">{workspace.default_environment}</span>
</header>
<div class="mt-8 grid gap-8 lg:grid-cols-[1fr_22rem]">
<div class="space-y-10">
<section>
<div class="flex items-center justify-between"><h2 class="text-xl font-semibold">"Projects"</h2><span class="text-sm text-ink/50">{projects.len()}</span></div>
<ul class="mt-4 space-y-4">{projects.clone().into_iter().map(|data| {
let project = data.project;
let issues = data.issues;
let versions = data.versions;
let documents = data.documents;
view! {
<li class="rounded-xl border border-ink/10 bg-white p-5">
<div class="flex items-center justify-between gap-4"><div><h3 class="font-semibold">{format!("{} · {}", project.key, project.name)}</h3><p class="mt-1 text-sm text-ink/55">{project.description}</p><p class="mt-1 font-mono text-xs text-ink/40">{project.slug}</p></div><span class="text-xs uppercase text-ink/45">{project.lifecycle}</span></div>
<ul class="mt-4 divide-y divide-ink/10 border-t border-ink/10">{issues.into_iter().map(|issue| view! { <li class="flex items-center gap-3 py-3 text-sm"><span class="font-mono text-ink/45">{format!("#{}", issue.number)}</span><span>{issue.title}</span></li> }).collect_view()}</ul>
<IssueForm project_id=project.id.inner().to_owned() refresh=refresh/>
<div class="mt-5 border-t border-ink/10 pt-4"><h4 class="text-xs font-semibold uppercase tracking-wide text-ink/45">"Versions"</h4><ul class="mt-2 flex flex-wrap gap-2">{versions.into_iter().map(|version| view! { <li class="rounded-full bg-ink/5 px-3 py-1 text-xs">{format!("{} ({}) · {}", version.name, version.slug, version.state)}</li> }).collect_view()}</ul><VersionForm project_id=project.id.inner().to_owned() refresh=refresh/></div>
<div class="mt-5 border-t border-ink/10 pt-4"><div class="flex items-center justify-between"><h4 class="text-xs font-semibold uppercase tracking-wide text-ink/45">"Project docs"</h4><span class="text-xs text-ink/40">{documents.len()}</span></div><ul class="mt-2 space-y-2">{documents.into_iter().map(|document| view! { <li class="rounded border border-ink/10 p-3"><div class="flex items-center justify-between gap-4"><p class="font-mono text-xs font-semibold">{document.path}</p><span class="font-mono text-[0.65rem] text-ink/35">{document.revision_id.inner().to_owned()}</span></div><pre class="mt-2 whitespace-pre-wrap text-xs text-ink/65">{document.content}</pre></li> }).collect_view()}</ul></div>
</li>
}}).collect_view()}</ul>
</section>
<section>
<div class="flex items-center justify-between"><h2 class="text-xl font-semibold">"Docs"</h2><span class="text-sm text-ink/50">{documents.len()}</span></div>
<ul class="mt-4 space-y-3">{documents.into_iter().map(|document| view! { <li class="rounded-xl border border-ink/10 bg-white p-5"><div class="flex items-center justify-between gap-4"><p class="font-mono text-sm font-semibold">{document.path}</p><span class="font-mono text-xs text-ink/35">{document.revision_id.inner().to_owned()}</span></div><pre class="mt-3 whitespace-pre-wrap text-sm text-ink/70">{document.content}</pre></li> }).collect_view()}</ul>
</section>
</div>
<aside class="space-y-5">
<section class="rounded-xl border border-ink/10 bg-white p-5"><div class="flex items-center justify-between"><h2 class="font-semibold">"Environments"</h2><span class="text-sm text-ink/50">{environments.len()}</span></div><ul class="mt-3 space-y-2">{environments.into_iter().map(|environment| view! { <li class="rounded border border-ink/10 px-3 py-2 text-sm"><p class="font-medium">{environment.name}</p><p class="mt-1 font-mono text-xs text-ink/45">{format!("source: {} · infra: {}", environment.default_source_branch, environment.infra_branch.unwrap_or_else(|| "not configured".to_owned()))}</p></li> }).collect_view()}</ul></section>
<EnvironmentForm workspace_id=workspace_id.clone() refresh=refresh/>
<section class="rounded-xl border border-ink/10 bg-white p-5"><div class="flex items-center justify-between"><h2 class="font-semibold">"Repositories"</h2><span class="text-sm text-ink/50">{repositories.len()}</span></div><ul class="mt-3 space-y-2">{repositories.into_iter().map(|repository| { let role = match repository.role { crate::collab::RepositoryRole::Source => "source", crate::collab::RepositoryRole::Infra => "infra" }; view! { <li class="rounded border border-ink/10 px-3 py-2 text-sm"><p class="font-medium">{format!("{}/{}", repository.owner, repository.name)}</p><p class="mt-1 font-mono text-xs text-ink/45">{format!("{} · {role}", repository.branch)}</p></li> } }).collect_view()}</ul></section>
<ProjectForm workspace_id=workspace_id.clone() refresh=refresh/>
<DocumentForm workspace_id=workspace_id.clone() refresh=refresh/>
<AttachRepositoryForm workspace_id=workspace_id refresh=refresh/>
</aside>
</div>
}.into_any()
}
Err(message) => view! { <p class="text-red-600">{message.clone()}</p> }.into_any(),
})}
</Suspense>
</main>
</div>
}
}