use frunk::Generic;
use maud::{Markup, PreEscaped, html};
use crate::{
capability::define_register_items,
components::{
ButtonLink, ButtonModalForm, ButtonSubmit, Crumb, DeleteConfirmation, FieldText,
FieldTitle, FormOpts, HtmlAttrs, LayoutMain, LayoutSidebar, ObjectList, PaginationPage,
ShellChrome, ShellScaffold, SidebarMenu, SidebarMenuItem, SidebarMenuModalForm,
SidebarNavLink, SlotCapability, SlotRegistrar, SwapKey, TableButtonFilter,
TableColumnHeader, TablePagination, TableRow, breadcrumbs, button_link, button_modal_form,
button_submit, column_sort_url, container_column, container_row, data_table_list_refresh,
detail, field_text, field_title, form, form_hx_get_route, form_hx_post_main,
form_hx_post_url, label, layout_main, layout_sidebar, modal, modal_keyed, pagination_pages,
row_attr_navigate_route, row_attr_select, row_attr_select_multi, shell_scaffold,
sidebar_menu, sidebar_menu_item_pane, sidebar_menu_modal_form_item, sidebar_nav_items_pane,
sort_indicator, table_button_filter, table_pagination, table_pagination_picker,
with_list_filter_common,
},
html_form::{FormCtx, HtmlForm},
http::ProvideRequestCaps,
picker::RenderPickerSelect,
template::{RenderTemplate, TemplateCapability, TemplateOf, TemplateRegistrar},
web::{
CreateModal, modal_create_href_for_picker, modal_create_post_query, modal_create_post_url,
modal_edit_post_url,
},
};
use super::forms::{
MoveForm, MoveFormField, VNodeEditForm, VNodeEditFormField, VNodeEditFormFlag, VNodeForm,
VNodeFormField, VNodeFormFlag, VNodeKind, VNodeKindField, VNodeMultiUploadForm,
VNodeMultiUploadFormField, VNodeNameFilterForm, VNodeNameFilterFormField, VNodeZipUploadForm,
VNodeZipUploadFormField,
};
use super::keys::{
VNodeBulkDeleteModalKey, VNodeCreateModalKey, VNodeDeleteModalKey, VNodeEditModalKey,
VNodeMultiUploadModalKey, VNodeSelectModalKey, VNodeSelectTableKey, VNodeTableKey,
VNodeZipUploadModalKey,
};
use super::routes::{
VNodeBrowseRouteTag, VNodeBulkDeletePostRouteTag, VNodeBulkMovePostRouteTag,
VNodeCreateGetInRouteTag, VNodeCreateGetRouteTag, VNodeCreatePostInRouteTag,
VNodeCreatePostRouteTag, VNodeDeleteGetRouteTag, VNodeDeletePostRouteTag, VNodeDetailRouteTag,
VNodeDownloadRootRouteTag, VNodeDownloadRouteTag, VNodeEditGetRouteTag, VNodeEditPostRouteTag,
VNodeFileSelectInRouteTag, VNodeFileSelectRouteTag, VNodeListRouteTag, VNodeMoveGetRouteTag,
VNodeMovePostRouteTag, VNodeMoveSelectInRouteTag, VNodeMoveSelectRouteTag,
VNodeSelectInRouteTag, VNodeSelectRouteTag, VNodeUploadGetInRouteTag, VNodeUploadGetRouteTag,
VNodeUploadPostInRouteTag, VNodeUploadPostRouteTag, VNodeZipUploadGetInRouteTag,
VNodeZipUploadGetRouteTag, VNodeZipUploadPostInRouteTag, VNodeZipUploadPostRouteTag,
};
define_register_items! {
plugin: FilesystemTag;
capability: TemplateCapability;
trait: TemplateRegistrar;
method: register_templates;
wrapper: TemplateOf;
bounds: [Clone, ProvideRequestCaps, Send, Sync];
hook: Hook;
items: [
ListIdx: VNodeListPageTag => VNodeListPage,
DetailIdx: VNodeDetailPageTag => VNodeDetailPage,
EditModalIdx: VNodeEditModalPageTag => VNodeEditModalPage,
MoveIdx: VNodeMoveFormPageTag => VNodeMoveFormPage,
BulkMoveIdx: VNodeBulkMoveFormPageTag => VNodeBulkMoveFormPage,
CreateModalIdx: VNodeCreateModalPageTag => VNodeCreateModalPage,
MultiUploadModalIdx: VNodeMultiUploadModalPageTag => VNodeMultiUploadModalPage,
ZipUploadModalIdx: VNodeZipUploadModalPageTag => VNodeZipUploadModalPage,
SelectIdx: VNodeSelectPageTag => VNodeSelectPage,
ConfirmIdx: VNodeConfirmDeletePageTag => VNodeConfirmDeletePage,
ConfirmBulkIdx: VNodeConfirmBulkDeletePageTag => VNodeConfirmBulkDeletePage,
]
}
fn app_scaffold(
_title: &str,
chrome: &ShellChrome,
sidebar: Markup,
crumbs: Markup,
body: Markup,
) -> Markup {
shell_scaffold(ShellScaffold {
title: "Lariv",
registry_head: chrome.head.clone(),
topbar_items: chrome.topbar_items.clone(),
right_sidebar: chrome.right_sidebar.clone(),
sidebar,
breadcrumbs: crumbs,
body,
..Default::default()
})
}
fn scaffold_pane(
sidebar: Markup,
crumbs: Markup,
body: Markup,
) -> crate::components::AppLayoutHtml {
layout_sidebar(LayoutSidebar {
sidebar,
breadcrumbs: crumbs,
content: body,
})
}
fn scaffold_main(crumbs: Markup, body: Markup) -> crate::components::MainContentHtml {
layout_main(LayoutMain {
breadcrumbs: crumbs,
content: body,
})
}
fn vnode_create_get_url(parent_id: i64) -> String {
if parent_id == 0 {
VNodeCreateGetRouteTag.url()
} else {
VNodeCreateGetInRouteTag::new(parent_id).url()
}
}
fn filesystem_list_crumbs() -> Markup {
breadcrumbs(&[Crumb {
label: "Filesystem",
href: None,
}])
}
fn filesystem_browse_crumbs(parent_name: &str) -> Markup {
let list_url = VNodeListRouteTag.url();
breadcrumbs(&[
Crumb {
label: "Filesystem",
href: Some(&list_url),
},
Crumb {
label: parent_name,
href: None,
},
])
}
fn filesystem_item_crumbs(id: i64, name: &str, action: Option<&str>) -> Markup {
let list_url = VNodeListRouteTag.url();
let detail_url = VNodeDetailRouteTag::new(id).url();
match action {
None => breadcrumbs(&[
Crumb {
label: "Filesystem",
href: Some(&list_url),
},
Crumb {
label: name,
href: None,
},
]),
Some(act) => breadcrumbs(&[
Crumb {
label: "Filesystem",
href: Some(&list_url),
},
Crumb {
label: name,
href: Some(&detail_url),
},
Crumb {
label: act,
href: None,
},
]),
}
}
fn main_menu(current_path: &str) -> Markup {
let list_url = VNodeListRouteTag.url();
let create_url = VNodeCreateGetRouteTag.url();
let upload_url = VNodeUploadGetRouteTag.url();
let zip_url = VNodeZipUploadGetRouteTag.url();
let nav = [SidebarNavLink {
key: "list",
title: "All Files",
url: &list_url,
icon_name: None,
match_prefixes: &[],
}];
sidebar_menu(SidebarMenu {
title: "Filesystem",
children: html! {
(sidebar_nav_items_pane(&nav, current_path))
(sidebar_menu_modal_form_item(SidebarMenuModalForm {
label: "Create Item",
href: &create_url,
name: "p_filesystem.VNodeCreateForm",
..Default::default()
}))
(sidebar_menu_modal_form_item(SidebarMenuModalForm {
label: "Bulk Upload",
href: &upload_url,
name: "p_filesystem.VNodeMultiUploadForm",
..Default::default()
}))
(sidebar_menu_modal_form_item(SidebarMenuModalForm {
label: "Upload Zip",
href: &zip_url,
name: "p_filesystem.VNodeZipUploadForm",
..Default::default()
}))
},
})
}
fn vnode_menu(id: i64, name: &str, is_directory: bool, active: &str) -> Markup {
let menu_title = format!("Item: {name}");
let detail_url = VNodeDetailRouteTag::new(id).url();
let move_url = VNodeMoveGetRouteTag::new(id).url();
let browse_url = VNodeBrowseRouteTag::new(id).url();
let create_get_url = VNodeCreateGetInRouteTag::new(id).url();
let upload_get_url = VNodeUploadGetInRouteTag::new(id).url();
let zip_upload_get_url = VNodeZipUploadGetInRouteTag::new(id).url();
sidebar_menu(SidebarMenu {
title: &menu_title,
children: html! {
(sidebar_menu_item_pane(SidebarMenuItem {
title: "View Details",
url: &detail_url,
active: active == "detail",
..Default::default()
}))
(sidebar_menu_item_pane(SidebarMenuItem {
title: "Move",
url: &move_url,
active: active == "move",
..Default::default()
}))
@if is_directory {
(sidebar_menu_item_pane(SidebarMenuItem {
title: "Browse Contents",
url: &browse_url,
active: active == "browse",
..Default::default()
}))
(sidebar_menu_modal_form_item(SidebarMenuModalForm {
label: "Add New Item",
href: &create_get_url,
name: "p_filesystem.VNodeCreateForm",
..Default::default()
}))
(sidebar_menu_modal_form_item(SidebarMenuModalForm {
label: "Bulk Upload",
href: &upload_get_url,
name: "p_filesystem.VNodeMultiUploadForm",
..Default::default()
}))
(sidebar_menu_modal_form_item(SidebarMenuModalForm {
label: "Upload Zip",
href: &zip_upload_get_url,
name: "p_filesystem.VNodeZipUploadForm",
..Default::default()
}))
}
},
})
}
fn vnode_filter_form<
K: SwapKey,
R: crate::http::FragmentGet<K> + crate::http::RouteUrl + Copy + Default,
>(
name: &str,
page_size: u32,
) -> Markup {
form(FormOpts {
attrs: form_hx_get_route::<K, R>(R::default()),
inputs: with_list_filter_common(
VNodeNameFilterForm::render_inputs(
&FormCtx::form::<VNodeNameFilterForm>().value(VNodeNameFilterFormField::Name, name),
),
page_size,
),
actions: html! {
(container_row(
"flex gap-2",
html! {
(button_submit(ButtonSubmit {
label: "Apply Filters",
..Default::default()
}))
(crate::components::button_clear(crate::components::ButtonClear {
label: "Clear",
..Default::default()
}))
},
))
},
..Default::default()
})
}
fn render_pagination<K: SwapKey>(
path_and_query: &str,
number: u32,
num_pages: u32,
push_url: bool,
) -> Markup {
let owned = pagination_pages(path_and_query, number, num_pages, push_url);
let pages: Vec<PaginationPage<'_>> = owned
.iter()
.map(|(ellipsis, url, push_url, active, label)| PaginationPage {
ellipsis: *ellipsis,
url: url.as_str(),
push_url: *push_url,
active: *active,
label: label.as_str(),
})
.collect();
table_pagination(TablePagination {
pages: &pages,
hx_target: K::SELECTOR,
})
}
#[derive(Clone)]
pub struct VNodeRow {
pub id: i64,
pub name: String,
pub is_directory: bool,
pub size_display: String,
pub items_display: String,
pub updated_at: String,
}
#[derive(Generic)]
pub struct VNodeListPage {
pub parent_id: i64,
pub parent_name: String,
pub items: ObjectList<VNodeRow>,
pub filter_name: String,
pub sort: String,
pub path_and_query: String,
pub page_size: u32,
}
impl VNodeListPage {
fn menu(&self) -> Markup {
if self.parent_id == 0 {
main_menu(&self.path_and_query)
} else {
vnode_menu(self.parent_id, &self.parent_name, true, "browse")
}
}
fn crumbs(&self) -> Markup {
if self.parent_id == 0 {
filesystem_list_crumbs()
} else {
filesystem_browse_crumbs(&self.parent_name)
}
}
fn selection_root_js() -> &'static str {
"Alpine.$data($el.closest('[data-vnode-selection]'))"
}
fn selection_x_data() -> &'static str {
r#"{
selected: {},
toggle(id) {
const k = String(id);
if (this.selected[k]) delete this.selected[k];
else this.selected[k] = true;
},
setVisible(ids, on) {
for (const id of ids) {
const k = String(id);
if (on) this.selected[k] = true;
else delete this.selected[k];
}
},
allVisibleSelected(ids) {
return ids.length > 0 && ids.every(id => !!this.selected[String(id)]);
},
someVisibleSelected(ids) {
return ids.some(id => !!this.selected[String(id)]);
},
selectedIds() {
return Object.keys(this.selected).filter(k => this.selected[k]);
},
returnTo() {
const path = window.location.pathname + window.location.search;
return path.startsWith('/filesystem') ? path : '/filesystem';
},
bulkDeleteHref() {
const ids = this.selectedIds();
if (ids.length < 1) return '#';
return '/filesystem/bulk-delete/?ids=' + ids.join(',')
+ '&return=' + encodeURIComponent(this.returnTo());
},
bulkMoveHref() {
const ids = this.selectedIds();
if (ids.length < 1) return '#';
return '/filesystem/bulk-move/?ids=' + ids.join(',')
+ '&return=' + encodeURIComponent(this.returnTo());
},
bulkDownloadHref() {
const ids = this.selectedIds();
if (ids.length < 1) return '#';
return '/filesystem/bulk-download/?ids=' + ids.join(',');
},
requestBulkDelete(el) {
const href = this.bulkDeleteHref();
if (href === '#' || typeof htmx === 'undefined') return;
htmx.ajax('GET', href, { target: 'body', swap: 'beforeend', source: el });
},
requestBulkMove(el) {
const href = this.bulkMoveHref();
if (href === '#' || typeof htmx === 'undefined') return;
htmx.ajax('GET', href, {
target: '#app-layout',
select: '#app-layout',
swap: 'outerHTML',
push: true,
source: el,
});
},
requestBulkDownload() {
const href = this.bulkDownloadHref();
if (href === '#') return;
window.location.assign(href);
}
}"#
}
fn wrap_with_selection(&self, table: Markup) -> Markup {
html! {
(PreEscaped(format!(
r#"<div data-vnode-selection x-data="{}">"#,
crate::components::attrs::escape_attr(Self::selection_x_data()),
)))
(table)
(PreEscaped("</div>"))
}
}
pub fn render_table(&self) -> Markup {
let sel = Self::selection_root_js();
let name_sort = column_sort_url(&self.path_and_query, "Name", &self.sort);
let type_sort = column_sort_url(&self.path_and_query, "Type", &self.sort);
let modified_sort = column_sort_url(&self.path_and_query, "Modified", &self.sort);
let name_label = format!("Name{}", sort_indicator(&self.sort, "Name"));
let type_label = format!("Type{}", sort_indicator(&self.sort, "Type"));
let modified_label = format!("Modified{}", sort_indicator(&self.sort, "Modified"));
let visible_ids: Vec<i64> = self.items.items.iter().map(|n| n.id).collect();
let visible_ids_js = format!(
"[{}]",
visible_ids
.iter()
.map(|id| id.to_string())
.collect::<Vec<_>>()
.join(",")
);
let select_all_label = format!(
r#"<label class="flex justify-center" @click.stop=""><input type="checkbox" class="checkbox checkbox-sm" @change="{sel}.setVisible({ids}, $event.target.checked)" :checked="{sel}.allVisibleSelected({ids})" x-effect="$el.indeterminate = {sel}.someVisibleSelected({ids}) && !{sel}.allVisibleSelected({ids})" /></label>"#,
sel = sel,
ids = visible_ids_js,
);
let headers = [
TableColumnHeader {
key: "Select",
label: &select_all_label,
sort_url: None,
push_url: true,
},
TableColumnHeader {
key: "Name",
label: &name_label,
sort_url: Some(&name_sort),
push_url: true,
},
TableColumnHeader {
key: "Type",
label: &type_label,
sort_url: Some(&type_sort),
push_url: true,
},
TableColumnHeader {
key: "Size",
label: "Size",
sort_url: None,
push_url: true,
},
TableColumnHeader {
key: "Items",
label: "Items",
sort_url: None,
push_url: true,
},
TableColumnHeader {
key: "Modified",
label: &modified_label,
sort_url: Some(&modified_sort),
push_url: true,
},
];
let rows: Vec<TableRow> = self
.items
.items
.iter()
.map(|n| {
let row_attrs = if n.is_directory {
row_attr_navigate_route(VNodeBrowseRouteTag::new(n.id))
} else {
row_attr_navigate_route(VNodeDetailRouteTag::new(n.id))
};
TableRow {
attrs: row_attrs,
cells: vec![
PreEscaped(format!(
r#"<label class="flex justify-center" @click.stop=""><input type="checkbox" class="checkbox checkbox-sm" @change="{sel}.toggle({id})" :checked="!!{sel}.selected['{id}']" /></label>"#,
sel = sel,
id = n.id,
))
.into(),
field_text(FieldText {
value: &n.name,
classes: "",
}),
field_text(FieldText {
value: if n.is_directory { "Directory" } else { "File" },
classes: "",
}),
field_text(FieldText {
value: &n.size_display,
classes: "",
}),
field_text(FieldText {
value: &n.items_display,
classes: "",
}),
field_text(FieldText {
value: &n.updated_at,
classes: "",
}),
],
}
})
.collect();
let filter_panel = if self.parent_id == 0 {
vnode_filter_form::<VNodeTableKey, VNodeListRouteTag>(&self.filter_name, self.page_size)
} else {
form(FormOpts {
attrs: crate::components::swap::form_hx_get_for_url::<VNodeTableKey>(
&VNodeBrowseRouteTag::new(self.parent_id).url(),
),
inputs: with_list_filter_common(
VNodeNameFilterForm::render_inputs(
&FormCtx::form::<VNodeNameFilterForm>()
.value(VNodeNameFilterFormField::Name, &self.filter_name),
),
self.page_size,
),
actions: html! {
(container_row(
"flex gap-2",
html! {
(button_submit(ButtonSubmit {
label: "Apply Filters",
..Default::default()
}))
(crate::components::button_clear(crate::components::ButtonClear {
label: "Clear",
..Default::default()
}))
},
))
},
..Default::default()
})
};
let create_href = vnode_create_get_url(self.parent_id);
let download_btn = if self.parent_id == 0 {
crate::components::button_download_route(
VNodeDownloadRootRouteTag,
"Download Zip",
"btn-outline btn-sm",
)
} else {
crate::components::button_download_route(
VNodeDownloadRouteTag::new(self.parent_id),
"Download Zip",
"btn-outline btn-sm",
)
};
let bulk_item = |label: &str, classes: &str, on_click: &str| {
format!(
r#"<button type="button" class="btn {classes} btn-sm justify-start w-full" x-bind:class="{sel}.selectedIds().length >= 1 ? '' : 'btn-disabled pointer-events-none opacity-50'" @click="{sel}.{on_click}($el); $el.closest('details')?.removeAttribute('open')">{label}</button>"#,
classes = classes,
sel = sel,
on_click = on_click,
label = label,
)
};
let bulk_items = format!(
"{}{}{}",
bulk_item("Move selected", "btn-ghost", "requestBulkMove"),
bulk_item("Download zip", "btn-ghost", "requestBulkDownload"),
bulk_item(
"Delete selected",
"btn-ghost text-error",
"requestBulkDelete"
),
);
let bulk_actions = html! {
(PreEscaped(
r#"<details class="dropdown dropdown-end" @click.outside="$el.removeAttribute('open')">"#,
))
summary class="btn btn-outline btn-sm dropdown-toggle w-32" {
"Bulk actions"
}
div class="card w-56 my-1.5 card-body shadow dropdown-content border border-base-300 rounded-box z-50 bg-base-100 p-2" {
div class="flex flex-col gap-1" {
(PreEscaped(bulk_items))
}
}
(PreEscaped("</details>"))
};
let actions = html! {
(table_button_filter(TableButtonFilter {
panel: filter_panel,
..Default::default()
}))
(download_btn)
(bulk_actions)
(button_modal_form(ButtonModalForm {
name: VNodeCreateModalKey::FORM_NAME,
href: &create_href,
form_post_url: "",
modal_uid: VNodeCreateModalKey::ID,
icon_name: Some("plus"),
classes: "btn-square btn-outline btn-sm",
..Default::default()
}))
};
let pagination = render_pagination::<VNodeTableKey>(
&self.path_and_query,
self.items.number,
self.items.num_pages,
true,
);
data_table_list_refresh::<VNodeTableKey>(
"",
actions,
&headers,
&rows,
pagination,
&self.path_and_query,
)
}
fn body(&self) -> Markup {
self.wrap_with_selection(self.render_table())
}
}
impl crate::template::RenderAppPane for VNodeListPage {
fn render_pane(&self) -> crate::components::AppLayoutHtml {
scaffold_pane(self.menu(), self.crumbs(), self.body())
}
fn render_main(&self) -> crate::components::MainContentHtml {
scaffold_main(self.crumbs(), self.body())
}
}
impl RenderTemplate for VNodeListPage {
fn render(&self, chrome: &ShellChrome) -> Markup {
app_scaffold(
"Filesystem — Lariv",
chrome,
self.menu(),
self.crumbs(),
self.body(),
)
}
}
#[derive(Generic)]
pub struct VNodeDetailPage {
pub id: i64,
pub name: String,
pub is_directory: bool,
pub item_type: String,
pub size_display: String,
pub items_display: String,
pub path: String,
pub updated_at: String,
}
impl VNodeDetailPage {
fn pane_body(&self) -> Markup {
let browse_url = VNodeBrowseRouteTag::new(self.id).url();
let edit_get = VNodeEditGetRouteTag::new(self.id).url();
let edit_post = VNodeEditPostRouteTag::new(self.id).path();
detail(html! {
(container_column(
"",
html! {
(field_title(FieldTitle {
value: &self.name,
classes: "",
}))
(label("Type", field_text(FieldText {
value: &self.item_type,
classes: "",
})))
(label("Size", field_text(FieldText {
value: &self.size_display,
classes: "",
})))
@if self.is_directory {
(label("Items", field_text(FieldText {
value: &self.items_display,
classes: "",
})))
}
(label("Path", field_text(FieldText {
value: &self.path,
classes: "",
})))
(label("Modified", field_text(FieldText {
value: &self.updated_at,
classes: "",
})))
(container_row(
"flex gap-2 mt-4",
html! {
(button_modal_form(ButtonModalForm {
name: "p_filesystem.VNodeEditForm",
href: &edit_get,
form_post_url: &edit_post,
modal_uid: VNodeEditModalKey::ID,
label: "Edit",
classes: "btn-outline",
..Default::default()
}))
@if self.is_directory {
(button_link(ButtonLink {
label: "Browse Contents",
href: &browse_url,
icon_name: Some("folder-open"),
..Default::default()
}))
} @else {
(crate::components::button_download_route(VNodeDownloadRouteTag::new(self.id),
"Download", "",
))
}
},
))
},
))
})
}
}
impl crate::template::RenderAppPane for VNodeDetailPage {
fn render_pane(&self) -> crate::components::AppLayoutHtml {
scaffold_pane(
vnode_menu(self.id, &self.name, self.is_directory, "detail"),
filesystem_item_crumbs(self.id, &self.name, None),
self.pane_body(),
)
}
fn render_main(&self) -> crate::components::MainContentHtml {
scaffold_main(
filesystem_item_crumbs(self.id, &self.name, None),
self.pane_body(),
)
}
}
impl RenderTemplate for VNodeDetailPage {
fn render(&self, chrome: &ShellChrome) -> Markup {
app_scaffold(
&format!("{} — Lariv", self.name),
chrome,
vnode_menu(self.id, &self.name, self.is_directory, "detail"),
filesystem_item_crumbs(self.id, &self.name, None),
self.pane_body(),
)
}
}
#[derive(Generic)]
pub struct VNodeEditModalPage {
pub id: i64,
pub form_name: String,
pub name: String,
pub is_directory: bool,
pub has_file: bool,
pub error: String,
}
impl RenderTemplate for VNodeEditModalPage {
fn render(&self, _chrome: &ShellChrome) -> Markup {
let delete_url = VNodeDeleteGetRouteTag::new(self.id).url();
let file_label = if self.has_file {
"Replace File"
} else {
"File"
};
let show_file_field = !self.is_directory;
let ctx = FormCtx::form::<VNodeEditForm>()
.value(VNodeEditFormField::Name, self.name.as_str())
.flag(VNodeEditFormFlag::ShowFile, show_file_field)
.label(VNodeEditFormField::File, file_label);
let inputs = VNodeEditForm::render_inputs(&ctx);
modal_keyed::<VNodeEditModalKey>(
&self.form_name,
html! {
h3 class="font-bold text-lg mb-4" { "Edit item" }
(form(FormOpts {
attrs: form_hx_post_url::<VNodeEditModalKey>(&modal_edit_post_url(
VNodeEditPostRouteTag::new(self.id),
&self.form_name,
))
.set("hx-encoding", "multipart/form-data"),
enctype: Some("multipart/form-data"),
form_error: Some(self.error.as_str()).filter(|e| !e.is_empty()),
inputs: container_column("", inputs),
actions: html! {
(button_submit(ButtonSubmit { label: "Save", ..Default::default() }))
(button_modal_form(ButtonModalForm {
label: "Delete",
icon_name: Some("trash"),
name: "p_filesystem.VNodeDeleteForm",
href: &delete_url,
form_post_url: &delete_url,
modal_uid: VNodeDeleteModalKey::ID,
classes: "btn-error",
..Default::default()
}))
},
..Default::default()
}))
},
)
}
}
#[derive(Generic)]
pub struct VNodeCreateModalPage {
pub form_name: String,
pub refresh_table: String,
pub target_input: String,
pub name: String,
pub is_directory: bool,
pub parent_id: i64,
pub parent_display: String,
pub error: String,
}
impl VNodeCreateModalPage {
fn post_url(&self, form_name: &str) -> String {
if self.parent_id == 0 {
modal_create_post_query(
VNodeCreatePostRouteTag,
form_name,
&self.refresh_table,
&self.target_input,
)
} else {
modal_create_post_query(
VNodeCreatePostInRouteTag::new(self.parent_id),
form_name,
&self.refresh_table,
&self.target_input,
)
}
}
fn inputs(&self) -> Markup {
let parent_id_s = if self.parent_id == 0 {
String::new()
} else {
self.parent_id.to_string()
};
let parent_val = if self.parent_id == 0 {
""
} else {
parent_id_s.as_str()
};
let ctx = FormCtx::form::<VNodeForm>()
.value(VNodeFormField::Name, self.name.as_str())
.flag(VNodeFormFlag::CreateMode, true)
.kind::<VNodeKind>("File")
.value(VNodeFormField::ParentId, parent_val)
.display(VNodeFormField::ParentId, self.parent_display.as_str())
.label(VNodeKindField::File, "File");
container_column("", VNodeForm::render_inputs(&ctx))
}
}
impl RenderTemplate for VNodeCreateModalPage {
fn render(&self, _chrome: &ShellChrome) -> Markup {
let form_name = if self.form_name.is_empty() {
VNodeCreateModalKey::FORM_NAME
} else {
self.form_name.as_str()
};
let post_url = self.post_url(form_name);
modal_keyed::<VNodeCreateModalKey>(
"",
form(FormOpts {
title: "Create Item",
subtitle: "Add a new file or folder",
classes: "@container",
attrs: form_hx_post_url::<VNodeCreateModalKey>(&post_url)
.set("hx-encoding", "multipart/form-data"),
enctype: Some("multipart/form-data"),
form_error: Some(self.error.as_str()).filter(|e| !e.is_empty()),
inputs: self.inputs(),
actions: html! {
(button_submit(ButtonSubmit {
label: "Save",
classes: "btn-primary",
..Default::default()
}))
},
..Default::default()
}),
)
}
}
#[derive(Generic)]
pub struct VNodeMoveFormPage {
pub id: i64,
pub name: String,
pub is_directory: bool,
pub destination_id: i64,
pub destination_display: String,
pub error: String,
}
impl VNodeMoveFormPage {
fn crumbs(&self) -> Markup {
filesystem_item_crumbs(self.id, &self.name, Some("Move"))
}
fn pane_body(&self) -> Markup {
let destination_id_s = if self.destination_id == 0 {
String::new()
} else {
self.destination_id.to_string()
};
let select_url = VNodeMoveSelectRouteTag
.with_query()
.query("exclude_id", self.id)
.build_with_query();
let ctx = FormCtx::form::<MoveForm>()
.value(MoveFormField::DestinationId, destination_id_s.as_str())
.display(
MoveFormField::DestinationId,
self.destination_display.as_str(),
)
.url(MoveFormField::DestinationId, select_url.as_str());
form(FormOpts {
title: "Move Item",
subtitle: &format!("Choose a new location for \"{}\"", self.name),
attrs: form_hx_post_main(VNodeMovePostRouteTag::new(self.id)),
form_error: Some(self.error.as_str()).filter(|e| !e.is_empty()),
inputs: MoveForm::render_inputs(&ctx),
actions: html! {
(button_submit(ButtonSubmit {
label: "Move",
..Default::default()
}))
},
..Default::default()
})
}
}
impl crate::template::RenderAppPane for VNodeMoveFormPage {
fn render_pane(&self) -> crate::components::AppLayoutHtml {
scaffold_pane(
vnode_menu(self.id, &self.name, self.is_directory, "move"),
self.crumbs(),
self.pane_body(),
)
}
fn render_main(&self) -> crate::components::MainContentHtml {
scaffold_main(self.crumbs(), self.pane_body())
}
}
impl RenderTemplate for VNodeMoveFormPage {
fn render(&self, chrome: &ShellChrome) -> Markup {
app_scaffold(
&format!("Move {} — Lariv", self.name),
chrome,
vnode_menu(self.id, &self.name, self.is_directory, "move"),
self.crumbs(),
self.pane_body(),
)
}
}
#[derive(Generic)]
pub struct VNodeBulkMoveFormPage {
pub ids: String,
pub count: usize,
pub destination_id: i64,
pub destination_display: String,
pub return_to: String,
pub error: String,
}
impl VNodeBulkMoveFormPage {
fn crumbs(&self) -> Markup {
let list_url = VNodeListRouteTag.url();
breadcrumbs(&[
Crumb {
label: "Filesystem",
href: Some(&list_url),
},
Crumb {
label: "Move selected",
href: None,
},
])
}
fn pane_body(&self) -> Markup {
let destination_id_s = if self.destination_id == 0 {
String::new()
} else {
self.destination_id.to_string()
};
let select_url = VNodeMoveSelectRouteTag.url();
let ctx = FormCtx::form::<MoveForm>()
.value(MoveFormField::DestinationId, destination_id_s.as_str())
.display(
MoveFormField::DestinationId,
self.destination_display.as_str(),
)
.url(MoveFormField::DestinationId, select_url.as_str());
let subtitle = if self.count == 1 {
"Choose a new location for the selected item".to_string()
} else {
format!("Choose a new location for {} selected items", self.count)
};
form(FormOpts {
title: "Move Selected",
subtitle: &subtitle,
attrs: form_hx_post_main(VNodeBulkMovePostRouteTag),
form_error: Some(self.error.as_str()).filter(|e| !e.is_empty()),
inputs: html! {
input type="hidden" name="ids" value=(self.ids);
input type="hidden" name="return" value=(self.return_to);
(MoveForm::render_inputs(&ctx))
},
actions: html! {
(button_submit(ButtonSubmit {
label: "Move",
..Default::default()
}))
},
..Default::default()
})
}
}
impl crate::template::RenderAppPane for VNodeBulkMoveFormPage {
fn render_pane(&self) -> crate::components::AppLayoutHtml {
scaffold_pane(main_menu("/filesystem"), self.crumbs(), self.pane_body())
}
fn render_main(&self) -> crate::components::MainContentHtml {
scaffold_main(self.crumbs(), self.pane_body())
}
}
impl RenderTemplate for VNodeBulkMoveFormPage {
fn render(&self, chrome: &ShellChrome) -> Markup {
app_scaffold(
"Move Selected — Lariv",
chrome,
main_menu("/filesystem"),
self.crumbs(),
self.pane_body(),
)
}
}
#[derive(Generic)]
pub struct VNodeMultiUploadModalPage {
pub form_name: String,
pub refresh_table: String,
pub parent_id: i64,
pub parent_display: String,
pub error: String,
}
impl VNodeMultiUploadModalPage {
fn post_url(&self, form_name: &str) -> String {
if self.parent_id == 0 {
modal_create_post_url(VNodeUploadPostRouteTag, form_name, &self.refresh_table)
} else {
modal_create_post_url(
VNodeUploadPostInRouteTag::new(self.parent_id),
form_name,
&self.refresh_table,
)
}
}
}
impl RenderTemplate for VNodeMultiUploadModalPage {
fn render(&self, _chrome: &ShellChrome) -> Markup {
let form_name = if self.form_name.is_empty() {
"p_filesystem.VNodeMultiUploadForm"
} else {
self.form_name.as_str()
};
let parent_id_s = if self.parent_id == 0 {
String::new()
} else {
self.parent_id.to_string()
};
let ctx = FormCtx::form::<VNodeMultiUploadForm>()
.value(VNodeMultiUploadFormField::ParentId, parent_id_s.as_str())
.display(
VNodeMultiUploadFormField::ParentId,
self.parent_display.as_str(),
);
modal_keyed::<VNodeMultiUploadModalKey>(
"",
form(FormOpts {
title: "Bulk Upload",
subtitle: "Upload multiple files at once",
attrs: form_hx_post_url::<VNodeMultiUploadModalKey>(&self.post_url(form_name))
.set("hx-encoding", "multipart/form-data"),
enctype: Some("multipart/form-data"),
form_error: Some(self.error.as_str()).filter(|e| !e.is_empty()),
inputs: VNodeMultiUploadForm::render_inputs(&ctx),
actions: html! {
(button_submit(ButtonSubmit {
label: "Upload",
classes: "btn-primary",
..Default::default()
}))
},
..Default::default()
}),
)
}
}
#[derive(Generic)]
pub struct VNodeZipUploadModalPage {
pub form_name: String,
pub refresh_table: String,
pub parent_id: i64,
pub parent_display: String,
pub error: String,
}
impl VNodeZipUploadModalPage {
fn post_url(&self, form_name: &str) -> String {
if self.parent_id == 0 {
modal_create_post_url(VNodeZipUploadPostRouteTag, form_name, &self.refresh_table)
} else {
modal_create_post_url(
VNodeZipUploadPostInRouteTag::new(self.parent_id),
form_name,
&self.refresh_table,
)
}
}
}
impl RenderTemplate for VNodeZipUploadModalPage {
fn render(&self, _chrome: &ShellChrome) -> Markup {
let form_name = if self.form_name.is_empty() {
"p_filesystem.VNodeZipUploadForm"
} else {
self.form_name.as_str()
};
let parent_id_s = if self.parent_id == 0 {
String::new()
} else {
self.parent_id.to_string()
};
let ctx = FormCtx::form::<VNodeZipUploadForm>()
.value(VNodeZipUploadFormField::ParentId, parent_id_s.as_str())
.display(
VNodeZipUploadFormField::ParentId,
self.parent_display.as_str(),
);
modal_keyed::<VNodeZipUploadModalKey>(
"",
form(FormOpts {
title: "Upload Zip",
subtitle: "Replaces the contents of the destination folder",
attrs: form_hx_post_url::<VNodeZipUploadModalKey>(&self.post_url(form_name))
.set("hx-encoding", "multipart/form-data"),
enctype: Some("multipart/form-data"),
form_error: Some(self.error.as_str()).filter(|e| !e.is_empty()),
inputs: VNodeZipUploadForm::render_inputs(&ctx),
actions: html! {
(button_submit(ButtonSubmit {
label: "Upload",
classes: "btn-primary",
..Default::default()
}))
},
..Default::default()
}),
)
}
}
#[derive(Clone)]
pub struct VNodeOption {
pub id: i64,
pub name: String,
pub is_directory: bool,
}
#[derive(Generic)]
pub struct VNodeSelectPage {
pub items: ObjectList<VNodeOption>,
pub filter_name: String,
pub target_input: String,
pub browse_base: String,
pub parent_id: i64,
pub up_parent_id: i64,
pub current_path: String,
pub exclude_id: i64,
pub only_directories: bool,
pub multi: bool,
pub sort: String,
pub path_and_query: String,
pub page_size: u32,
}
fn form_hx_get_picker_url<M: SwapKey>(url: &str) -> HtmlAttrs {
HtmlAttrs::new()
.set("method", "GET")
.set("hx-get", url)
.set("hx-target", M::SELECTOR)
.set("hx-swap", "outerHTML")
.set("hx-push-url", "false")
}
fn render_picker_pagination<M: SwapKey>(
path_and_query: &str,
number: u32,
num_pages: u32,
) -> Markup {
let owned = pagination_pages(path_and_query, number, num_pages, false);
let pages: Vec<PaginationPage<'_>> = owned
.iter()
.map(|(ellipsis, url, push_url, active, label)| PaginationPage {
ellipsis: *ellipsis,
url: url.as_str(),
push_url: *push_url,
active: *active,
label: label.as_str(),
})
.collect();
table_pagination_picker(TablePagination {
pages: &pages,
hx_target: M::SELECTOR,
})
}
impl VNodeSelectPage {
fn is_file_select(&self) -> bool {
self.browse_base.contains("file-select")
}
fn is_move_select(&self) -> bool {
self.browse_base.contains("move-select")
}
fn title(&self) -> &'static str {
if self.only_directories {
"Select a Folder"
} else {
"Select a File"
}
}
fn browse_route_url(&self, parent_id: i64) -> String {
let target = (!self.target_input.is_empty()).then_some(self.target_input.as_str());
let exclude = (self.exclude_id != 0).then_some(self.exclude_id);
let multi = self.multi.then_some(1u8);
match (self.is_move_select(), self.is_file_select(), parent_id) {
(true, _, 0) => VNodeMoveSelectRouteTag
.with_query()
.query_opt("target_input", target)
.query_opt("exclude_id", exclude)
.query_opt("multi", multi)
.build(),
(true, _, pid) => VNodeMoveSelectInRouteTag::new(pid)
.with_query()
.query_opt("target_input", target)
.query_opt("exclude_id", exclude)
.query_opt("multi", multi)
.build(),
(_, true, 0) => VNodeFileSelectRouteTag
.with_query()
.query_opt("target_input", target)
.query_opt("exclude_id", exclude)
.query_opt("multi", multi)
.build(),
(_, true, pid) => VNodeFileSelectInRouteTag::new(pid)
.with_query()
.query_opt("target_input", target)
.query_opt("exclude_id", exclude)
.query_opt("multi", multi)
.build(),
(_, _, 0) => VNodeSelectRouteTag
.with_query()
.query_opt("target_input", target)
.query_opt("exclude_id", exclude)
.query_opt("multi", multi)
.build(),
(_, _, pid) => VNodeSelectInRouteTag::new(pid)
.with_query()
.query_opt("target_input", target)
.query_opt("exclude_id", exclude)
.query_opt("multi", multi)
.build(),
}
}
fn browse_hx_attrs(&self, parent_id: i64) -> HtmlAttrs {
HtmlAttrs::new()
.set("hx-get", self.browse_route_url(parent_id))
.set("hx-target", VNodeSelectModalKey::SELECTOR)
.set("hx-swap", "outerHTML")
.set("hx-push-url", "false")
}
fn browse_button(&self, parent_id: i64, label: &str) -> Markup {
let attrs = self
.browse_hx_attrs(parent_id)
.set("type", "button")
.set("class", "btn btn-ghost btn-xs")
.set("@click.stop", "");
maud::PreEscaped(format!("<button{}>{label}</button>", attrs.as_string()))
}
}
impl RenderPickerSelect<VNodeSelectTableKey, VNodeSelectModalKey> for VNodeSelectPage {
fn render_table(&self) -> Markup {
let target = if self.target_input.is_empty() {
"ParentID"
} else {
self.target_input.as_str()
};
let name_sort = column_sort_url(&self.path_and_query, "Name", &self.sort);
let name_label = format!("Name{}", sort_indicator(&self.sort, "Name"));
let headers = [
TableColumnHeader {
key: "Name",
label: &name_label,
sort_url: Some(&name_sort),
push_url: false,
},
TableColumnHeader {
key: "Actions",
label: "",
sort_url: None,
push_url: false,
},
];
let rows: Vec<TableRow> = self
.items
.items
.iter()
.filter(|n| n.id != self.exclude_id)
.map(|n| {
let selectable = self.only_directories || !n.is_directory;
let attrs = if selectable {
if self.multi {
row_attr_select_multi(target, &n.id.to_string(), &n.name)
} else {
row_attr_select(target, &n.id.to_string(), &n.name)
}
} else {
self.browse_hx_attrs(n.id)
};
let open = if n.is_directory {
self.browse_button(n.id, "Open")
} else {
html! {}
};
TableRow {
attrs,
cells: vec![
field_text(FieldText {
value: &n.name,
classes: "",
}),
open,
],
}
})
.collect();
let root_select = row_attr_select(target, "0", "Filesystem root");
let create_href = modal_create_href_for_picker(
&vnode_create_get_url(self.parent_id),
VNodeCreateModalKey::FORM_NAME,
&self.target_input,
);
let filter_inputs = html! {
(with_list_filter_common(
VNodeNameFilterForm::render_inputs(
&FormCtx::form::<VNodeNameFilterForm>()
.value(VNodeNameFilterFormField::Name, &self.filter_name),
),
self.page_size,
))
input type="hidden" name="target_input" value=(self.target_input) {}
@if self.exclude_id != 0 {
input type="hidden" name="exclude_id" value=(self.exclude_id) {}
}
@if self.multi {
input type="hidden" name="multi" value="1" {}
}
};
let actions = html! {
(table_button_filter(TableButtonFilter {
panel: form(FormOpts {
attrs: form_hx_get_picker_url::<VNodeSelectModalKey>(
&self.browse_route_url(self.parent_id),
),
inputs: filter_inputs,
actions: html! {
(button_submit(ButtonSubmit {
label: "Apply",
..Default::default()
}))
},
..Default::default()
}),
..Default::default()
}))
(button_modal_form(ButtonModalForm {
name: "",
href: &create_href,
form_post_url: "",
modal_uid: VNodeCreateModalKey::ID,
icon_name: Some("plus"),
classes: "btn-square btn-outline btn-sm",
..Default::default()
}))
};
let pagination = render_picker_pagination::<VNodeSelectModalKey>(
&self.path_and_query,
self.items.number,
self.items.num_pages,
);
let use_here_label = format!("Use here: {}", self.current_path);
html! {
@if self.parent_id != 0 {
div class="mb-2 flex flex-wrap gap-2" {
(self.browse_button(self.up_parent_id, "Up"))
@if self.only_directories {
(maud::PreEscaped(format!(
r#"<div class="btn btn-sm btn-outline"{}>"#,
root_select.as_string()
)))
(use_here_label)
(maud::PreEscaped("</div>"))
}
}
}
(data_table_list_refresh::<VNodeSelectTableKey>(
self.title(),
actions,
&headers,
&rows,
pagination,
&self.path_and_query,
))
}
}
}
impl RenderTemplate for VNodeSelectPage {
fn render(&self, _chrome: &ShellChrome) -> Markup {
self.render_modal().into_inner()
}
}
#[derive(Generic)]
pub struct VNodeConfirmDeletePage {
pub modal_uid: String,
pub message: String,
pub form_name: String,
pub id: i64,
pub error: String,
}
impl RenderTemplate for VNodeConfirmDeletePage {
fn render(&self, _chrome: &ShellChrome) -> Markup {
let target = if self.modal_uid.is_empty() {
format!("#{}", VNodeDeleteModalKey::ID)
} else {
format!("#{}", self.modal_uid)
};
let uid = if self.modal_uid.is_empty() {
VNodeDeleteModalKey::ID
} else {
self.modal_uid.as_str()
};
modal(crate::components::Modal {
uid,
children: crate::components::delete_confirmation(DeleteConfirmation {
title: "Confirm Deletion",
message: &self.message,
attrs: crate::components::form_hx_post_selector(
&VNodeDeletePostRouteTag::new(self.id).url(),
&target,
),
form_error: Some(self.error.as_str()).filter(|e| !e.is_empty()),
..Default::default()
}),
..Default::default()
})
}
}
#[derive(Generic)]
pub struct VNodeConfirmBulkDeletePage {
pub modal_uid: String,
pub message: String,
pub form_name: String,
pub ids: String,
pub return_to: String,
pub error: String,
pub can_submit: bool,
}
impl RenderTemplate for VNodeConfirmBulkDeletePage {
fn render(&self, _chrome: &ShellChrome) -> Markup {
let target = if self.modal_uid.is_empty() {
format!("#{}", VNodeBulkDeleteModalKey::ID)
} else {
format!("#{}", self.modal_uid)
};
let uid = if self.modal_uid.is_empty() {
VNodeBulkDeleteModalKey::ID
} else {
self.modal_uid.as_str()
};
let post_url = VNodeBulkDeletePostRouteTag.url();
let form_attrs = crate::components::form_hx_post_selector(&post_url, &target);
modal(crate::components::Modal {
uid,
children: html! {
div class="container mx-auto" {
h2 class="text-xl font-bold text-error" { "Confirm Deletion" }
p class="my-2" { (self.message) }
@if !self.error.is_empty() {
div class="alert alert-error my-2 text-sm" { (self.error) }
}
@if self.can_submit {
(PreEscaped(format!(
r#"<form class="flex flex-col gap-2 my-4"{}>"#,
form_attrs.as_string(),
)))
input type="hidden" name="ids" value=(self.ids);
input type="hidden" name="return" value=(self.return_to);
div class="my-2" {
(button_submit(ButtonSubmit {
label: "Confirm Delete",
classes: "btn-error my-2",
..Default::default()
}))
}
(PreEscaped("</form>"))
}
}
},
..Default::default()
})
}
}
define_register_items! {
plugin: FilesystemTag;
capability: SlotCapability;
trait: SlotRegistrar;
method: register_slots;
bounds: [];
items: [];
hook: SlotsHook;
}
#[cfg(test)]
mod vnode_form_page_tests {
use super::{
VNodeCreateModalPage, VNodeDetailPage, VNodeEditModalPage, VNodeListPage, VNodeOption,
VNodeSelectPage,
};
use crate::components::ObjectList;
use crate::picker::RenderPickerSelect;
use crate::template::{RenderAppPane, RenderTemplate};
#[test]
fn create_modal_shows_kind_radios() {
let page = VNodeCreateModalPage {
form_name: String::new(),
refresh_table: String::new(),
target_input: String::new(),
name: String::new(),
is_directory: false,
parent_id: 0,
parent_display: String::new(),
error: String::new(),
};
let html = page.render(&Default::default()).into_string();
assert!(html.contains("name=\"Kind\""), "create: {html}");
assert!(html.contains("type=\"radio\""), "create: {html}");
assert!(html.contains("type=\"file\""), "create: {html}");
assert!(html.contains("name=\"ParentID\""), "create: {html}");
assert!(html.contains("multipart/form-data"), "create: {html}");
assert!(html.contains("onchange="), "create: {html}");
assert!(html.contains("files[0].name"), "create: {html}");
}
#[test]
fn create_modal_post_includes_target_input() {
let page = VNodeCreateModalPage {
form_name: String::new(),
refresh_table: String::new(),
target_input: "ParentID".into(),
name: String::new(),
is_directory: false,
parent_id: 0,
parent_display: String::new(),
error: String::new(),
};
let html = page.render(&Default::default()).into_string();
assert!(html.contains("target_input=ParentID"), "create: {html}");
}
fn select_page(parent_id: i64, target_input: &str) -> VNodeSelectPage {
select_page_with(parent_id, target_input, "/filesystem/select", true, vec![])
}
fn select_page_with(
parent_id: i64,
target_input: &str,
browse_base: &str,
only_directories: bool,
items: Vec<VNodeOption>,
) -> VNodeSelectPage {
VNodeSelectPage {
items: ObjectList::from_page(items, 1, 12, 0),
filter_name: String::new(),
target_input: target_input.into(),
browse_base: browse_base.into(),
parent_id,
up_parent_id: 0,
current_path: if parent_id == 0 {
"/".into()
} else {
"/docs".into()
},
exclude_id: 0,
only_directories,
multi: false,
sort: String::new(),
path_and_query: browse_base.into(),
page_size: 12,
}
}
#[test]
fn select_modal_matches_other_pickers() {
let html = select_page(0, "ParentID")
.render(&Default::default())
.into_string();
assert!(html.contains("<dialog"), "select: {html}");
assert!(html.contains("vnode-selection-modal"), "select: {html}");
assert!(html.contains("modal-open"), "select: {html}");
assert!(!html.contains("!bg-transparent"), "select: {html}");
}
#[test]
fn select_table_is_fragment_not_dialog() {
let html = select_page(0, "ParentID").render_table().into_string();
assert!(!html.contains("<dialog"), "select table: {html}");
assert!(html.contains("fk-modal-host"), "select table: {html}");
assert!(html.contains("Select a Folder"), "select table: {html}");
}
#[test]
fn select_table_create_button_at_root_embeds_target_input() {
let html = select_page(0, "ParentID").render_table().into_string();
assert!(html.contains("fk-modal-host"), "select: {html}");
assert!(html.contains("/filesystem/create/"), "select: {html}");
assert!(
html.contains("name=p_filesystem.VNodeCreateForm"),
"select: {html}"
);
assert!(html.contains("target_input=ParentID"), "select: {html}");
}
#[test]
fn select_table_create_button_in_directory_uses_parent_route() {
let html = select_page(7, "ParentID").render_table().into_string();
assert!(html.contains("/filesystem/create/in/7/"), "select: {html}");
assert!(html.contains("target_input=ParentID"), "select: {html}");
assert!(
!html.contains(r#"hx-get="/filesystem/create/?name="#),
"select: {html}"
);
assert!(html.contains(">Up</button>"), "select: {html}");
assert!(html.contains("Use here:"), "select: {html}");
}
#[test]
fn file_select_browse_stays_on_file_select_routes() {
let page = select_page_with(
0,
"PageID",
"/filesystem/file-select",
false,
vec![
VNodeOption {
id: 3,
name: "docs".into(),
is_directory: true,
},
VNodeOption {
id: 4,
name: "readme.txt".into(),
is_directory: false,
},
],
);
let html = page.render_table().into_string();
assert!(html.contains("Select a File"), "file select: {html}");
assert!(
html.contains("/filesystem/file-select/in/3"),
"file select: {html}"
);
assert!(
!html.contains("/filesystem/select/in/3"),
"file select: {html}"
);
assert!(html.contains(">Open</button>"), "file select: {html}");
assert!(html.contains("readme.txt"), "file select: {html}");
assert!(html.contains("fk-select"), "file select single: {html}");
assert!(
!html.contains("fk-multi-select"),
"file select single: {html}"
);
}
#[test]
fn file_select_multi_uses_multi_select_and_keeps_multi_query() {
let mut page = select_page_with(
0,
"Files",
"/filesystem/file-select",
false,
vec![
VNodeOption {
id: 3,
name: "docs".into(),
is_directory: true,
},
VNodeOption {
id: 4,
name: "readme.txt".into(),
is_directory: false,
},
],
);
page.multi = true;
let html = page.render_table().into_string();
assert!(html.contains("fk-multi-select"), "file multi: {html}");
assert!(
!html.contains("closest('dialog.modal')"),
"file multi must not close modal: {html}"
);
assert!(
html.contains(r#"name="multi" value="1""#),
"file multi filter: {html}"
);
assert!(
html.contains("multi=1"),
"browse/open must preserve multi: {html}"
);
}
#[test]
fn list_page_has_bulk_actions() {
let page = VNodeListPage {
parent_id: 0,
parent_name: String::new(),
items: ObjectList::from_page(vec![], 1, 12, 0),
filter_name: String::new(),
sort: String::new(),
path_and_query: "/filesystem".into(),
page_size: 12,
};
let html = page.render_table().into_string();
assert!(
html.contains("view: "List""),
"filesystem list should default to List: {html}"
);
assert!(
html.contains("Bulk actions"),
"filesystem list should expose bulk actions: {html}"
);
assert!(
html.contains("data-vnode-selection") || html.contains("setVisible"),
"filesystem list table should wire selection helpers: {html}"
);
}
#[test]
fn detail_page_shows_breadcrumbs() {
let page = VNodeDetailPage {
id: 42,
name: "docs".into(),
is_directory: true,
item_type: "Directory".into(),
size_display: "—".into(),
items_display: "3".into(),
path: "/docs".into(),
updated_at: "2026-01-01".into(),
};
let html = page.render_main().into_markup().into_string();
assert!(html.contains(r#"class="breadcrumbs"#), "detail: {html}");
assert!(html.contains("/filesystem"), "detail: {html}");
assert!(html.contains(">Filesystem</a>"), "detail: {html}");
assert!(html.contains("<span>docs</span>"), "detail: {html}");
}
#[test]
fn edit_dir_hides_file() {
let page = VNodeEditModalPage {
id: 1,
form_name: "p_filesystem.VNodeEditForm".into(),
name: "docs".into(),
is_directory: true,
has_file: false,
error: String::new(),
};
let html = page.render(&Default::default()).into_string();
assert!(!html.contains("type=\"radio\""), "edit dir: {html}");
assert!(!html.contains("type=\"file\""), "edit dir: {html}");
assert!(html.contains("name=\"Name\""), "edit dir: {html}");
}
#[test]
fn edit_file_shows_replace_file() {
let page = VNodeEditModalPage {
id: 2,
form_name: "p_filesystem.VNodeEditForm".into(),
name: "a.txt".into(),
is_directory: false,
has_file: true,
error: String::new(),
};
let html = page.render(&Default::default()).into_string();
assert!(html.contains("type=\"file\""), "edit file: {html}");
assert!(html.contains("Replace File"), "edit file: {html}");
}
}