feat(layout/search_definition): integrate view options directly in search parameters

This commit is contained in:
Elian Doran 2025-12-14 20:48:50 +02:00
parent 6b9b9a96c3
commit a7ca839afb
No known key found for this signature in database
7 changed files with 424 additions and 412 deletions

View File

@ -889,7 +889,8 @@
"search_parameters": "Search Parameters", "search_parameters": "Search Parameters",
"unknown_search_option": "Unknown search option {{searchOptionName}}", "unknown_search_option": "Unknown search option {{searchOptionName}}",
"search_note_saved": "Search note has been saved into {{- notePathTitle}}", "search_note_saved": "Search note has been saved into {{- notePathTitle}}",
"actions_executed": "Actions have been executed." "actions_executed": "Actions have been executed.",
"view_options": "View options:"
}, },
"similar_notes": { "similar_notes": {
"title": "Similar Notes", "title": "Similar Notes",

View File

@ -4,29 +4,8 @@ body.experimental-feature-new-layout {
} }
.title-actions { .title-actions {
padding: 0;
display: flex;
gap: 0.25em;
align-items: center;
width: 100%;
max-width: unset;
padding-inline-start: 15px; padding-inline-start: 15px;
padding-top: 1em;
padding-bottom: 0.2em; padding-bottom: 0.2em;
font-size: 0.8em;
.collapsible-title {
font-size: 1rem;
}
.dropdown-menu {
input.form-control {
padding: 2px 8px;
margin-left: 1em;
}
}
.spacer {
flex-grow: 1;
}
} }
} }

View File

@ -0,0 +1,20 @@
.collection-properties {
padding: 0;
display: flex;
gap: 0.25em;
align-items: center;
width: 100%;
max-width: unset;
font-size: 0.8em;
.dropdown-menu {
input.form-control {
padding: 2px 8px;
margin-left: 1em;
}
}
.spacer {
flex-grow: 1;
}
}

View File

@ -1,9 +1,14 @@
import "./CollectionProperties.css";
import { t } from "i18next"; import { t } from "i18next";
import { useContext } from "preact/hooks"; import { useContext } from "preact/hooks";
import { Fragment } from "preact/jsx-runtime"; import { Fragment } from "preact/jsx-runtime";
import FNote from "../../entities/fnote"; import FNote from "../../entities/fnote";
import { getHelpUrlForNote } from "../../services/in_app_help";
import { openInAppHelpFromUrl } from "../../services/utils";
import { ViewTypeOptions } from "../collections/interface"; import { ViewTypeOptions } from "../collections/interface";
import ActionButton from "../react/ActionButton";
import Dropdown from "../react/Dropdown"; import Dropdown from "../react/Dropdown";
import { FormDropdownDivider, FormDropdownSubmenu, FormListItem, FormListToggleableItem } from "../react/FormList"; import { FormDropdownDivider, FormDropdownSubmenu, FormListItem, FormListToggleableItem } from "../react/FormList";
import FormTextBox from "../react/FormTextBox"; import FormTextBox from "../react/FormTextBox";
@ -12,9 +17,6 @@ import Icon from "../react/Icon";
import { ParentComponent } from "../react/react_utils"; import { ParentComponent } from "../react/react_utils";
import { bookPropertiesConfig, BookProperty, ButtonProperty, CheckBoxProperty, ComboBoxItem, ComboBoxProperty, NumberProperty, SplitButtonProperty } from "../ribbon/collection-properties-config"; import { bookPropertiesConfig, BookProperty, ButtonProperty, CheckBoxProperty, ComboBoxItem, ComboBoxProperty, NumberProperty, SplitButtonProperty } from "../ribbon/collection-properties-config";
import { useViewType, VIEW_TYPE_MAPPINGS } from "../ribbon/CollectionPropertiesTab"; import { useViewType, VIEW_TYPE_MAPPINGS } from "../ribbon/CollectionPropertiesTab";
import ActionButton from "../react/ActionButton";
import { getHelpUrlForNote } from "../../services/in_app_help";
import { openInAppHelpFromUrl } from "../../services/utils";
const ICON_MAPPINGS: Record<ViewTypeOptions, string> = { const ICON_MAPPINGS: Record<ViewTypeOptions, string> = {
grid: "bx bxs-grid", grid: "bx bxs-grid",
@ -30,12 +32,12 @@ export default function CollectionProperties({ note }: { note: FNote }) {
const [ viewType, setViewType ] = useViewType(note); const [ viewType, setViewType ] = useViewType(note);
return ( return (
<> <div className="collection-properties">
<ViewTypeSwitcher viewType={viewType} setViewType={setViewType} /> <ViewTypeSwitcher viewType={viewType} setViewType={setViewType} />
<ViewOptions note={note} viewType={viewType} /> <ViewOptions note={note} viewType={viewType} />
<div className="spacer" /> <div className="spacer" />
<HelpButton note={note} /> <HelpButton note={note} />
</> </div>
); );
} }
@ -187,9 +189,9 @@ function ComboBoxPropertyView({ note, property }: { note: FNote, property: Combo
{index < property.options.length - 1 && <FormDropdownDivider />} {index < property.options.length - 1 && <FormDropdownDivider />}
</Fragment> </Fragment>
); );
} else {
return renderItem(option);
} }
return renderItem(option);
})} })}
</FormDropdownSubmenu> </FormDropdownSubmenu>
); );

View File

@ -60,7 +60,7 @@ export const RIBBON_TAB_DEFINITIONS: TabConfiguration[] = [
title: t("book_properties.book_properties"), title: t("book_properties.book_properties"),
icon: "bx bx-book", icon: "bx bx-book",
content: CollectionPropertiesTab, content: CollectionPropertiesTab,
show: ({ note }) => !isNewLayout && note?.type === "book" || note?.type === "search", show: ({ note }) => !isNewLayout && (note?.type === "book" || note?.type === "search"),
toggleCommand: "toggleRibbonTabBookProperties" toggleCommand: "toggleRibbonTabBookProperties"
}, },
{ {

View File

@ -1,360 +1,361 @@
import FormTextArea from "../react/FormTextArea"; import { AttributeType } from "@triliumnext/commons";
import NoteAutocomplete from "../react/NoteAutocomplete";
import FormSelect from "../react/FormSelect";
import Icon from "../react/Icon";
import FormTextBox from "../react/FormTextBox";
import { ComponentChildren, VNode } from "preact"; import { ComponentChildren, VNode } from "preact";
import { useEffect, useMemo, useRef } from "preact/hooks";
import appContext from "../../components/app_context";
import FNote from "../../entities/fnote"; import FNote from "../../entities/fnote";
import { removeOwnedAttributesByNameOrType } from "../../services/attributes"; import { removeOwnedAttributesByNameOrType } from "../../services/attributes";
import { AttributeType } from "@triliumnext/commons";
import { useNoteLabel, useNoteRelation, useSpacedUpdate, useTooltip } from "../react/hooks";
import { t } from "../../services/i18n"; import { t } from "../../services/i18n";
import { useEffect, useMemo, useRef } from "preact/hooks";
import appContext from "../../components/app_context";
import server from "../../services/server"; import server from "../../services/server";
import FormSelect from "../react/FormSelect";
import FormTextArea from "../react/FormTextArea";
import FormTextBox from "../react/FormTextBox";
import HelpRemoveButtons from "../react/HelpRemoveButtons"; import HelpRemoveButtons from "../react/HelpRemoveButtons";
import { useNoteLabel, useNoteRelation, useSpacedUpdate, useTooltip } from "../react/hooks";
import Icon from "../react/Icon";
import NoteAutocomplete from "../react/NoteAutocomplete";
export interface SearchOption { export interface SearchOption {
attributeName: string; attributeName: string;
attributeType: "label" | "relation"; attributeType: "label" | "relation";
icon: string; icon: string;
label: string; label: string;
tooltip?: string; tooltip?: string;
component: (props: SearchOptionProps) => VNode; component: (props: SearchOptionProps) => VNode;
defaultValue?: string; defaultValue?: string;
additionalAttributesToDelete?: { type: "label" | "relation", name: string }[]; additionalAttributesToDelete?: { type: "label" | "relation", name: string }[];
} }
interface SearchOptionProps { interface SearchOptionProps {
note: FNote; note: FNote;
refreshResults: () => void; refreshResults: () => void;
attributeName: string; attributeName: string;
attributeType: "label" | "relation"; attributeType: "label" | "relation";
additionalAttributesToDelete?: { type: "label" | "relation", name: string }[]; additionalAttributesToDelete?: { type: "label" | "relation", name: string }[];
defaultValue?: string; defaultValue?: string;
error?: { message: string }; error?: { message: string };
} }
export const SEARCH_OPTIONS: SearchOption[] = [ export const SEARCH_OPTIONS: SearchOption[] = [
{ {
attributeName: "searchString", attributeName: "searchString",
attributeType: "label", attributeType: "label",
icon: "bx bx-text", icon: "bx bx-text",
label: t("search_definition.search_string"), label: t("search_definition.search_string"),
component: SearchStringOption component: SearchStringOption
}, },
{ {
attributeName: "searchScript", attributeName: "searchScript",
attributeType: "relation", attributeType: "relation",
defaultValue: "root", defaultValue: "root",
icon: "bx bx-code", icon: "bx bx-code",
label: t("search_definition.search_script"), label: t("search_definition.search_script"),
component: SearchScriptOption component: SearchScriptOption
}, },
{ {
attributeName: "ancestor", attributeName: "ancestor",
attributeType: "relation", attributeType: "relation",
defaultValue: "root", defaultValue: "root",
icon: "bx bx-filter-alt", icon: "bx bx-filter-alt",
label: t("search_definition.ancestor"), label: t("search_definition.ancestor"),
component: AncestorOption, component: AncestorOption,
additionalAttributesToDelete: [ { type: "label", name: "ancestorDepth" } ] additionalAttributesToDelete: [ { type: "label", name: "ancestorDepth" } ]
}, },
{ {
attributeName: "fastSearch", attributeName: "fastSearch",
attributeType: "label", attributeType: "label",
icon: "bx bx-run", icon: "bx bx-run",
label: t("search_definition.fast_search"), label: t("search_definition.fast_search"),
tooltip: t("search_definition.fast_search_description"), tooltip: t("search_definition.fast_search_description"),
component: FastSearchOption component: FastSearchOption
}, },
{ {
attributeName: "includeArchivedNotes", attributeName: "includeArchivedNotes",
attributeType: "label", attributeType: "label",
icon: "bx bx-archive", icon: "bx bx-archive",
label: t("search_definition.include_archived"), label: t("search_definition.include_archived"),
tooltip: t("search_definition.include_archived_notes_description"), tooltip: t("search_definition.include_archived_notes_description"),
component: IncludeArchivedNotesOption component: IncludeArchivedNotesOption
}, },
{ {
attributeName: "orderBy", attributeName: "orderBy",
attributeType: "label", attributeType: "label",
defaultValue: "relevancy", defaultValue: "relevancy",
icon: "bx bx-arrow-from-top", icon: "bx bx-arrow-from-top",
label: t("search_definition.order_by"), label: t("search_definition.order_by"),
component: OrderByOption, component: OrderByOption,
additionalAttributesToDelete: [ { type: "label", name: "orderDirection" } ] additionalAttributesToDelete: [ { type: "label", name: "orderDirection" } ]
}, },
{ {
attributeName: "limit", attributeName: "limit",
attributeType: "label", attributeType: "label",
defaultValue: "10", defaultValue: "10",
icon: "bx bx-stop", icon: "bx bx-stop",
label: t("search_definition.limit"), label: t("search_definition.limit"),
tooltip: t("search_definition.limit_description"), tooltip: t("search_definition.limit_description"),
component: LimitOption component: LimitOption
}, },
{ {
attributeName: "debug", attributeName: "debug",
attributeType: "label", attributeType: "label",
icon: "bx bx-bug", icon: "bx bx-bug",
label: t("search_definition.debug"), label: t("search_definition.debug"),
tooltip: t("search_definition.debug_description"), tooltip: t("search_definition.debug_description"),
component: DebugOption component: DebugOption
} }
]; ];
function SearchOption({ note, title, titleIcon, children, help, attributeName, attributeType, additionalAttributesToDelete }: { function SearchOption({ note, title, titleIcon, children, help, attributeName, attributeType, additionalAttributesToDelete }: {
note: FNote; note: FNote;
title: string, title: string,
titleIcon?: string, titleIcon?: string,
children?: ComponentChildren, children?: ComponentChildren,
help?: ComponentChildren, help?: ComponentChildren,
attributeName: string, attributeName: string,
attributeType: AttributeType, attributeType: AttributeType,
additionalAttributesToDelete?: { type: "label" | "relation", name: string }[] additionalAttributesToDelete?: { type: "label" | "relation", name: string }[]
}) { }) {
return ( return (
<tr className={attributeName}> <tr className={attributeName}>
<td className="title-column"> <td className="title-column">
{titleIcon && <><Icon icon={titleIcon} />{" "}</>} {titleIcon && <><Icon icon={titleIcon} />{" "}</>}
{title} {title}
</td> </td>
<td>{children}</td> <td>{children}</td>
<HelpRemoveButtons <HelpRemoveButtons
help={help} help={help}
removeText={t("abstract_search_option.remove_this_search_option")} removeText={t("abstract_search_option.remove_this_search_option")}
onRemove={() => { onRemove={() => {
removeOwnedAttributesByNameOrType(note, attributeType, attributeName); removeOwnedAttributesByNameOrType(note, attributeType, attributeName);
if (additionalAttributesToDelete) { if (additionalAttributesToDelete) {
for (const { type, name } of additionalAttributesToDelete) { for (const { type, name } of additionalAttributesToDelete) {
removeOwnedAttributesByNameOrType(note, type, name); removeOwnedAttributesByNameOrType(note, type, name);
} }
} }
}} }}
/> />
</tr> </tr>
) );
} }
function SearchStringOption({ note, refreshResults, error, ...restProps }: SearchOptionProps) { function SearchStringOption({ note, refreshResults, error, ...restProps }: SearchOptionProps) {
const [ searchString, setSearchString ] = useNoteLabel(note, "searchString"); const [ searchString, setSearchString ] = useNoteLabel(note, "searchString");
const inputRef = useRef<HTMLTextAreaElement>(null); const inputRef = useRef<HTMLTextAreaElement>(null);
const currentValue = useRef(searchString ?? ""); const currentValue = useRef(searchString ?? "");
const spacedUpdate = useSpacedUpdate(async () => { const spacedUpdate = useSpacedUpdate(async () => {
const searchString = currentValue.current; const searchString = currentValue.current;
appContext.lastSearchString = searchString; appContext.lastSearchString = searchString;
setSearchString(searchString); setSearchString(searchString);
if (note.title.startsWith(t("search_string.search_prefix"))) { if (note.title.startsWith(t("search_string.search_prefix"))) {
await server.put(`notes/${note.noteId}/title`, { await server.put(`notes/${note.noteId}/title`, {
title: `${t("search_string.search_prefix")} ${searchString.length < 30 ? searchString : `${searchString.substr(0, 30)}`}` title: `${t("search_string.search_prefix")} ${searchString.length < 30 ? searchString : `${searchString.substr(0, 30)}`}`
}); });
}
}, 1000);
// React to errors
const { showTooltip, hideTooltip } = useTooltip(inputRef, {
trigger: "manual",
title: `${t("search_string.error", { error: error?.message })}`,
html: true,
placement: "bottom"
});
// Auto-focus.
useEffect(() => inputRef.current?.focus(), []);
useEffect(() => {
if (error) {
showTooltip();
setTimeout(() => hideTooltip(), 4000);
} else {
hideTooltip();
}
}, [ error ]);
return <SearchOption
title={t("search_string.title_column")}
help={<>
<strong>{t("search_string.search_syntax")}</strong> - {t("search_string.also_see")} <a href="#" data-help-page="search.html">{t("search_string.complete_help")}</a>
<ul style="marigin-bottom: 0;">
<li>{t("search_string.full_text_search")}</li>
<li><code>#abc</code> - {t("search_string.label_abc")}</li>
<li><code>#year = 2019</code> - {t("search_string.label_year")}</li>
<li><code>#rock #pop</code> - {t("search_string.label_rock_pop")}</li>
<li><code>#rock or #pop</code> - {t("search_string.label_rock_or_pop")}</li>
<li><code>#year &lt;= 2000</code> - {t("search_string.label_year_comparison")}</li>
<li><code>note.dateCreated &gt;= MONTH-1</code> - {t("search_string.label_date_created")}</li>
</ul>
</>}
note={note} {...restProps}
>
<FormTextArea
inputRef={inputRef}
className="search-string"
placeholder={t("search_string.placeholder")}
currentValue={searchString ?? ""}
onChange={text => {
currentValue.current = text;
spacedUpdate.scheduleUpdate();
}}
onKeyDown={async (e) => {
if (e.key === "Enter") {
e.preventDefault();
// this also in effect disallows new lines in query string.
// on one hand, this makes sense since search string is a label
// on the other hand, it could be nice for structuring long search string. It's probably a niche case though.
await spacedUpdate.updateNowIfNecessary();
refreshResults();
} }
}} }, 1000);
/>
</SearchOption> // React to errors
const { showTooltip, hideTooltip } = useTooltip(inputRef, {
trigger: "manual",
title: `${t("search_string.error", { error: error?.message })}`,
html: true,
placement: "bottom"
});
// Auto-focus.
useEffect(() => inputRef.current?.focus(), []);
useEffect(() => {
if (error) {
showTooltip();
setTimeout(() => hideTooltip(), 4000);
} else {
hideTooltip();
}
}, [ error ]);
return <SearchOption
title={t("search_string.title_column")}
help={<>
<strong>{t("search_string.search_syntax")}</strong> - {t("search_string.also_see")} <a href="#" data-help-page="search.html">{t("search_string.complete_help")}</a>
<ul style="marigin-bottom: 0;">
<li>{t("search_string.full_text_search")}</li>
<li><code>#abc</code> - {t("search_string.label_abc")}</li>
<li><code>#year = 2019</code> - {t("search_string.label_year")}</li>
<li><code>#rock #pop</code> - {t("search_string.label_rock_pop")}</li>
<li><code>#rock or #pop</code> - {t("search_string.label_rock_or_pop")}</li>
<li><code>#year &lt;= 2000</code> - {t("search_string.label_year_comparison")}</li>
<li><code>note.dateCreated &gt;= MONTH-1</code> - {t("search_string.label_date_created")}</li>
</ul>
</>}
note={note} {...restProps}
>
<FormTextArea
inputRef={inputRef}
className="search-string"
placeholder={t("search_string.placeholder")}
currentValue={searchString ?? ""}
onChange={text => {
currentValue.current = text;
spacedUpdate.scheduleUpdate();
}}
onKeyDown={async (e) => {
if (e.key === "Enter") {
e.preventDefault();
// this also in effect disallows new lines in query string.
// on one hand, this makes sense since search string is a label
// on the other hand, it could be nice for structuring long search string. It's probably a niche case though.
await spacedUpdate.updateNowIfNecessary();
refreshResults();
}
}}
/>
</SearchOption>;
} }
function SearchScriptOption({ note, ...restProps }: SearchOptionProps) { function SearchScriptOption({ note, ...restProps }: SearchOptionProps) {
const [ searchScript, setSearchScript ] = useNoteRelation(note, "searchScript"); const [ searchScript, setSearchScript ] = useNoteRelation(note, "searchScript");
return <SearchOption return <SearchOption
title={t("search_script.title")} title={t("search_script.title")}
help={<> help={<>
<p>{t("search_script.description1")}</p> <p>{t("search_script.description1")}</p>
<p>{t("search_script.description2")}</p> <p>{t("search_script.description2")}</p>
<p>{t("search_script.example_title")}</p> <p>{t("search_script.example_title")}</p>
<pre>{t("search_script.example_code")}</pre> <pre>{t("search_script.example_code")}</pre>
{t("search_script.note")} {t("search_script.note")}
</>} </>}
note={note} {...restProps} note={note} {...restProps}
> >
<NoteAutocomplete <NoteAutocomplete
noteId={searchScript !== "root" ? searchScript ?? undefined : undefined} noteId={searchScript !== "root" ? searchScript ?? undefined : undefined}
noteIdChanged={noteId => setSearchScript(noteId ?? "root")} noteIdChanged={noteId => setSearchScript(noteId ?? "root")}
placeholder={t("search_script.placeholder")} placeholder={t("search_script.placeholder")}
/> />
</SearchOption> </SearchOption>;
} }
function AncestorOption({ note, ...restProps}: SearchOptionProps) { function AncestorOption({ note, ...restProps}: SearchOptionProps) {
const [ ancestor, setAncestor ] = useNoteRelation(note, "ancestor"); const [ ancestor, setAncestor ] = useNoteRelation(note, "ancestor");
const [ depth, setDepth ] = useNoteLabel(note, "ancestorDepth"); const [ depth, setDepth ] = useNoteLabel(note, "ancestorDepth");
const options = useMemo(() => { const options = useMemo(() => {
const options: { value: string | undefined; label: string }[] = [ const options: { value: string | undefined; label: string }[] = [
{ value: "", label: t("ancestor.depth_doesnt_matter") }, { value: "", label: t("ancestor.depth_doesnt_matter") },
{ value: "eq1", label: `${t("ancestor.depth_eq", { count: 1 })} (${t("ancestor.direct_children")})` } { value: "eq1", label: `${t("ancestor.depth_eq", { count: 1 })} (${t("ancestor.direct_children")})` }
]; ];
for (let i=2; i<=9; i++) options.push({ value: "eq" + i, label: t("ancestor.depth_eq", { count: i }) }); for (let i=2; i<=9; i++) options.push({ value: `eq${ i}`, label: t("ancestor.depth_eq", { count: i }) });
for (let i=0; i<=9; i++) options.push({ value: "gt" + i, label: t("ancestor.depth_gt", { count: i }) }); for (let i=0; i<=9; i++) options.push({ value: `gt${ i}`, label: t("ancestor.depth_gt", { count: i }) });
for (let i=2; i<=9; i++) options.push({ value: "lt" + i, label: t("ancestor.depth_lt", { count: i }) }); for (let i=2; i<=9; i++) options.push({ value: `lt${ i}`, label: t("ancestor.depth_lt", { count: i }) });
return options; return options;
}, []); }, []);
return <SearchOption return <SearchOption
title={t("ancestor.label")} title={t("ancestor.label")}
note={note} {...restProps} note={note} {...restProps}
> >
<div style={{display: "flex", alignItems: "center"}}> <div style={{display: "flex", alignItems: "center"}}>
<NoteAutocomplete <NoteAutocomplete
noteId={ancestor !== "root" ? ancestor ?? undefined : undefined} noteId={ancestor !== "root" ? ancestor ?? undefined : undefined}
noteIdChanged={noteId => setAncestor(noteId ?? "root")} noteIdChanged={noteId => setAncestor(noteId ?? "root")}
placeholder={t("ancestor.placeholder")} placeholder={t("ancestor.placeholder")}
/> />
<div style="margin-inline-start: 10px; margin-inline-end: 10px">{t("ancestor.depth_label")}:</div> <div style="margin-inline-start: 10px; margin-inline-end: 10px">{t("ancestor.depth_label")}:</div>
<FormSelect <FormSelect
values={options} values={options}
keyProperty="value" titleProperty="label" keyProperty="value" titleProperty="label"
currentValue={depth ?? ""} onChange={(value) => setDepth(value ? value : null)} currentValue={depth ?? ""} onChange={(value) => setDepth(value ? value : null)}
style={{ flexShrink: 3 }} style={{ flexShrink: 3 }}
/> />
</div> </div>
</SearchOption>; </SearchOption>;
} }
function FastSearchOption({ ...restProps }: SearchOptionProps) { function FastSearchOption({ ...restProps }: SearchOptionProps) {
return <SearchOption return <SearchOption
titleIcon="bx bx-run" title={t("fast_search.fast_search")} titleIcon="bx bx-run" title={t("fast_search.fast_search")}
help={t("fast_search.description")} help={t("fast_search.description")}
{...restProps} {...restProps}
/> />;
} }
function DebugOption({ ...restProps }: SearchOptionProps) { function DebugOption({ ...restProps }: SearchOptionProps) {
return <SearchOption return <SearchOption
titleIcon="bx bx-bug" title={t("debug.debug")} titleIcon="bx bx-bug" title={t("debug.debug")}
help={<> help={<>
<p>{t("debug.debug_info")}</p> <p>{t("debug.debug_info")}</p>
{t("debug.access_info")} {t("debug.access_info")}
</>} </>}
{...restProps} {...restProps}
/> />;
} }
function IncludeArchivedNotesOption({ ...restProps }: SearchOptionProps) { function IncludeArchivedNotesOption({ ...restProps }: SearchOptionProps) {
return <SearchOption return <SearchOption
titleIcon="bx bx-archive" title={t("include_archived_notes.include_archived_notes")} titleIcon="bx bx-archive" title={t("include_archived_notes.include_archived_notes")}
{...restProps} {...restProps}
/> />;
} }
function OrderByOption({ note, ...restProps }: SearchOptionProps) { function OrderByOption({ note, ...restProps }: SearchOptionProps) {
const [ orderBy, setOrderBy ] = useNoteLabel(note, "orderBy"); const [ orderBy, setOrderBy ] = useNoteLabel(note, "orderBy");
const [ orderDirection, setOrderDirection ] = useNoteLabel(note, "orderDirection"); const [ orderDirection, setOrderDirection ] = useNoteLabel(note, "orderDirection");
return <SearchOption return <SearchOption
titleIcon="bx bx-arrow-from-top" titleIcon="bx bx-arrow-from-top"
title={t("order_by.order_by")} title={t("order_by.order_by")}
note={note} {...restProps} note={note} {...restProps}
> >
<FormSelect <FormSelect
className="w-auto d-inline" className="w-auto d-inline"
currentValue={orderBy ?? "relevancy"} onChange={setOrderBy} currentValue={orderBy ?? "relevancy"} onChange={setOrderBy}
keyProperty="value" titleProperty="title" keyProperty="value" titleProperty="title"
values={[ values={[
{ value: "relevancy", title: t("order_by.relevancy") }, { value: "relevancy", title: t("order_by.relevancy") },
{ value: "title", title: t("order_by.title") }, { value: "title", title: t("order_by.title") },
{ value: "dateCreated", title: t("order_by.date_created") }, { value: "dateCreated", title: t("order_by.date_created") },
{ value: "dateModified", title: t("order_by.date_modified") }, { value: "dateModified", title: t("order_by.date_modified") },
{ value: "contentSize", title: t("order_by.content_size") }, { value: "contentSize", title: t("order_by.content_size") },
{ value: "contentAndAttachmentsSize", title: t("order_by.content_and_attachments_size") }, { value: "contentAndAttachmentsSize", title: t("order_by.content_and_attachments_size") },
{ value: "contentAndAttachmentsAndRevisionsSize", title: t("order_by.content_and_attachments_and_revisions_size") }, { value: "contentAndAttachmentsAndRevisionsSize", title: t("order_by.content_and_attachments_and_revisions_size") },
{ value: "revisionCount", title: t("order_by.revision_count") }, { value: "revisionCount", title: t("order_by.revision_count") },
{ value: "childrenCount", title: t("order_by.children_count") }, { value: "childrenCount", title: t("order_by.children_count") },
{ value: "parentCount", title: t("order_by.parent_count") }, { value: "parentCount", title: t("order_by.parent_count") },
{ value: "ownedLabelCount", title: t("order_by.owned_label_count") }, { value: "ownedLabelCount", title: t("order_by.owned_label_count") },
{ value: "ownedRelationCount", title: t("order_by.owned_relation_count") }, { value: "ownedRelationCount", title: t("order_by.owned_relation_count") },
{ value: "targetRelationCount", title: t("order_by.target_relation_count") }, { value: "targetRelationCount", title: t("order_by.target_relation_count") },
{ value: "random", title: t("order_by.random") } { value: "random", title: t("order_by.random") }
]} ]}
/> />
{" "} {" "}
<FormSelect <FormSelect
className="w-auto d-inline" className="w-auto d-inline"
currentValue={orderDirection ?? "asc"} onChange={setOrderDirection} currentValue={orderDirection ?? "asc"} onChange={setOrderDirection}
keyProperty="value" titleProperty="title" keyProperty="value" titleProperty="title"
values={[ values={[
{ value: "asc", title: t("order_by.asc") }, { value: "asc", title: t("order_by.asc") },
{ value: "desc", title: t("order_by.desc") } { value: "desc", title: t("order_by.desc") }
]} ]}
/> />
</SearchOption> </SearchOption>;
} }
function LimitOption({ note, defaultValue, ...restProps }: SearchOptionProps) { function LimitOption({ note, defaultValue, ...restProps }: SearchOptionProps) {
const [ limit, setLimit ] = useNoteLabel(note, "limit"); const [ limit, setLimit ] = useNoteLabel(note, "limit");
return <SearchOption return <SearchOption
titleIcon="bx bx-stop" titleIcon="bx bx-stop"
title={t("limit.limit")} title={t("limit.limit")}
help={t("limit.take_first_x_results")} help={t("limit.take_first_x_results")}
note={note} {...restProps} note={note} {...restProps}
> >
<FormTextBox <FormTextBox
type="number" min="1" step="1" type="number" min="1" step="1"
currentValue={limit ?? defaultValue} onChange={setLimit} currentValue={limit ?? defaultValue} onChange={setLimit}
/> />
</SearchOption> </SearchOption>;
} }

View File

@ -7,6 +7,7 @@ import appContext from "../../components/app_context";
import FNote from "../../entities/fnote"; import FNote from "../../entities/fnote";
import attributes from "../../services/attributes"; import attributes from "../../services/attributes";
import bulk_action, { ACTION_GROUPS } from "../../services/bulk_action"; import bulk_action, { ACTION_GROUPS } from "../../services/bulk_action";
import { isExperimentalFeatureEnabled } from "../../services/experimental_features";
import froca from "../../services/froca"; import froca from "../../services/froca";
import { t } from "../../services/i18n"; import { t } from "../../services/i18n";
import server from "../../services/server"; import server from "../../services/server";
@ -15,6 +16,7 @@ import tree from "../../services/tree";
import { getErrorMessage } from "../../services/utils"; import { getErrorMessage } from "../../services/utils";
import ws from "../../services/ws"; import ws from "../../services/ws";
import RenameNoteBulkAction from "../bulk_actions/note/rename_note"; import RenameNoteBulkAction from "../bulk_actions/note/rename_note";
import CollectionProperties from "../note_bars/CollectionProperties";
import Button from "../react/Button"; import Button from "../react/Button";
import Dropdown from "../react/Dropdown"; import Dropdown from "../react/Dropdown";
import { FormListHeader, FormListItem } from "../react/FormList"; import { FormListHeader, FormListItem } from "../react/FormList";
@ -24,6 +26,8 @@ import { ParentComponent } from "../react/react_utils";
import { TabContext } from "./ribbon-interface"; import { TabContext } from "./ribbon-interface";
import { SEARCH_OPTIONS, SearchOption } from "./SearchDefinitionOptions"; import { SEARCH_OPTIONS, SearchOption } from "./SearchDefinitionOptions";
const isNewLayout = isExperimentalFeatureEnabled("new-layout");
export default function SearchDefinitionTab({ note, ntxId, hidden }: Pick<TabContext, "note" | "ntxId" | "hidden">) { export default function SearchDefinitionTab({ note, ntxId, hidden }: Pick<TabContext, "note" | "ntxId" | "hidden">) {
const parentComponent = useContext(ParentComponent); const parentComponent = useContext(ParentComponent);
const [ searchOptions, setSearchOptions ] = useState<{ availableOptions: SearchOption[], activeOptions: SearchOption[] }>(); const [ searchOptions, setSearchOptions ] = useState<{ availableOptions: SearchOption[], activeOptions: SearchOption[] }>();
@ -78,85 +82,90 @@ export default function SearchDefinitionTab({ note, ntxId, hidden }: Pick<TabCon
return ( return (
<div className="search-definition-widget"> <div className="search-definition-widget">
<div className="search-settings"> <div className="search-settings">
{note && !hidden && {note && !hidden && (
<table className="search-setting-table"> <table className="search-setting-table">
<tbody> <tbody>
<tr> <tr>
<td className="title-column">{t("search_definition.add_search_option")}</td> <td className="title-column">{t("search_definition.add_search_option")}</td>
<td colSpan={2} className="add-search-option"> <td colSpan={2} className="add-search-option">
{searchOptions?.availableOptions.map(({ icon, label, tooltip, attributeName, attributeType, defaultValue }) => ( {searchOptions?.availableOptions.map(({ icon, label, tooltip, attributeName, attributeType, defaultValue }) => (
<Button <Button
size="small" size="small"
icon={icon} icon={icon}
text={label} text={label}
title={tooltip} title={tooltip}
onClick={() => attributes.setAttribute(note, attributeType, attributeName, defaultValue ?? "")} onClick={() => attributes.setAttribute(note, attributeType, attributeName, defaultValue ?? "")}
/> />
))} ))}
<AddBulkActionButton note={note} /> <AddBulkActionButton note={note} />
</td> </td>
</tr> </tr>
</tbody> </tbody>
<tbody className="search-options"> <tbody className="search-options">
{searchOptions?.activeOptions.map(({ attributeType, attributeName, component, additionalAttributesToDelete, defaultValue }) => { {searchOptions?.activeOptions.map(({ attributeType, attributeName, component, additionalAttributesToDelete, defaultValue }) => {
const Component = component; const Component = component;
return <Component return <Component
attributeName={attributeName} attributeName={attributeName}
attributeType={attributeType} attributeType={attributeType}
note={note} note={note}
refreshResults={refreshResults} refreshResults={refreshResults}
error={error} error={error}
additionalAttributesToDelete={additionalAttributesToDelete} additionalAttributesToDelete={additionalAttributesToDelete}
defaultValue={defaultValue} defaultValue={defaultValue}
/>; />;
})} })}
</tbody>
<BulkActionsList note={note} />
<tbody className="search-actions">
<tr>
<td colSpan={3}>
<div className="search-actions-container">
<Button
icon="bx bx-search"
text={t("search_definition.search_button")}
keyboardShortcut="Enter"
onClick={refreshResults}
/>
<Button {isNewLayout && <tr className="view-options">
icon="bx bxs-zap" <td className="title-column">{t("search_definition.view_options")}</td>
text={t("search_definition.search_execute")} <td><CollectionProperties note={note} /></td>
onClick={async () => { </tr>}
await server.post(`search-and-execute-note/${note.noteId}`); </tbody>
refreshResults(); <BulkActionsList note={note} />
toast.showMessage(t("search_definition.actions_executed"), 3000); <tbody className="search-actions">
}} <tr>
/> <td colSpan={3}>
<div className="search-actions-container">
<Button
icon="bx bx-search"
text={t("search_definition.search_button")}
keyboardShortcut="Enter"
onClick={refreshResults}
/>
{note.isHiddenCompletely() && <Button <Button
icon="bx bx-save" icon="bx bxs-zap"
text={t("search_definition.save_to_note")} text={t("search_definition.search_execute")}
onClick={async () => { onClick={async () => {
const { notePath } = await server.post<SaveSearchNoteResponse>("special-notes/save-search-note", { searchNoteId: note.noteId }); await server.post(`search-and-execute-note/${note.noteId}`);
if (!notePath) { refreshResults();
return; toast.showMessage(t("search_definition.actions_executed"), 3000);
} }}
/>
await ws.waitForMaxKnownEntityChangeId(); {note.isHiddenCompletely() && <Button
await appContext.tabManager.getActiveContext()?.setNote(notePath); icon="bx bx-save"
text={t("search_definition.save_to_note")}
onClick={async () => {
const { notePath } = await server.post<SaveSearchNoteResponse>("special-notes/save-search-note", { searchNoteId: note.noteId });
if (!notePath) {
return;
}
// Note the {{- notePathTitle}} in json file is not typo, it's unescaping await ws.waitForMaxKnownEntityChangeId();
// See https://www.i18next.com/translation-function/interpolation#unescape await appContext.tabManager.getActiveContext()?.setNote(notePath);
toast.showMessage(t("search_definition.search_note_saved", { notePathTitle: await tree.getNotePathTitle(notePath) }));
}} // Note the {{- notePathTitle}} in json file is not typo, it's unescaping
/>} // See https://www.i18next.com/translation-function/interpolation#unescape
</div> toast.showMessage(t("search_definition.search_note_saved", { notePathTitle: await tree.getNotePathTitle(notePath) }));
</td> }}
</tr> />}
</tbody> </div>
</table> </td>
} </tr>
</tbody>
</table>
)}
</div> </div>
</div> </div>
); );