mirror of
https://github.com/zadam/trilium.git
synced 2025-10-20 15:19:01 +02:00
feat(react/dialogs): port bulk actions
This commit is contained in:
parent
8d27a5aa39
commit
f9eb0a20f7
7
apps/client/src/widgets/bulk_actions/BulkAction.tsx
Normal file
7
apps/client/src/widgets/bulk_actions/BulkAction.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
interface BulkActionProps {
|
||||
|
||||
}
|
||||
|
||||
export default function BulkAction() {
|
||||
|
||||
}
|
24
apps/client/src/widgets/dialogs/bulk_actions.css
Normal file
24
apps/client/src/widgets/dialogs/bulk_actions.css
Normal file
@ -0,0 +1,24 @@
|
||||
.bulk-actions-dialog .modal-body h4:not(:first-child) {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.bulk-actions-dialog .bulk-available-action-list button {
|
||||
padding: 2px 7px;
|
||||
margin-right: 10px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.bulk-actions-dialog .bulk-existing-action-list {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.bulk-actions-dialog .bulk-existing-action-list td {
|
||||
padding: 7px;
|
||||
}
|
||||
|
||||
.bulk-actions-dialog .bulk-existing-action-list .button-column {
|
||||
/* minimal width so that table remains static sized and most space remains for middle column with settings */
|
||||
width: 50px;
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
}
|
@ -1,175 +0,0 @@
|
||||
import BasicWidget from "../basic_widget.js";
|
||||
import froca from "../../services/froca.js";
|
||||
import bulkActionService from "../../services/bulk_action.js";
|
||||
import server from "../../services/server.js";
|
||||
import toastService from "../../services/toast.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import type { EventData } from "../../components/app_context.js";
|
||||
import { closeActiveDialog, openDialog } from "../../services/dialog.js";
|
||||
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="bulk-actions-dialog modal mx-auto" tabindex="-1" role="dialog">
|
||||
<style>
|
||||
.bulk-actions-dialog .modal-body h4:not(:first-child) {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.bulk-actions-dialog .bulk-available-action-list button {
|
||||
padding: 2px 7px;
|
||||
margin-right: 10px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.bulk-actions-dialog .bulk-existing-action-list {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.bulk-actions-dialog .bulk-existing-action-list td {
|
||||
padding: 7px;
|
||||
}
|
||||
|
||||
.bulk-actions-dialog .bulk-existing-action-list .button-column {
|
||||
/* minimal width so that table remains static sized and most space remains for middle column with settings */
|
||||
width: 50px;
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="modal-dialog modal-xl" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">${t("bulk_actions.bulk_actions")}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="${t("bulk_actions.close")}"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<h4>${t("bulk_actions.affected_notes")}: <span class="affected-note-count">0</span></h4>
|
||||
|
||||
<div class="form-check">
|
||||
<label for="include-descendants" class="form-check-label tn-checkbox">
|
||||
<input id="include-descendants" class="include-descendants form-check-input" type="checkbox" value="">
|
||||
${t("bulk_actions.include_descendants")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<h4>${t("bulk_actions.available_actions")}</h4>
|
||||
|
||||
<table class="bulk-available-action-list"></table>
|
||||
|
||||
<h4>${t("bulk_actions.chosen_actions")}</h4>
|
||||
|
||||
<table class="bulk-existing-action-list"></table>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="submit" class="execute-bulk-actions btn btn-primary">${t("bulk_actions.execute_bulk_actions")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
export default class BulkActionsDialog extends BasicWidget {
|
||||
private $includeDescendants!: JQuery<HTMLElement>;
|
||||
private $affectedNoteCount!: JQuery<HTMLElement>;
|
||||
private $availableActionList!: JQuery<HTMLElement>;
|
||||
private $existingActionList!: JQuery<HTMLElement>;
|
||||
private $executeButton!: JQuery<HTMLElement>;
|
||||
private selectedOrActiveNoteIds: string[] | null = null;
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.$includeDescendants = this.$widget.find(".include-descendants");
|
||||
this.$includeDescendants.on("change", () => this.refresh());
|
||||
|
||||
this.$affectedNoteCount = this.$widget.find(".affected-note-count");
|
||||
|
||||
this.$availableActionList = this.$widget.find(".bulk-available-action-list");
|
||||
this.$existingActionList = this.$widget.find(".bulk-existing-action-list");
|
||||
|
||||
this.$widget.on("click", "[data-action-add]", async (event) => {
|
||||
const actionName = $(event.target).attr("data-action-add");
|
||||
if (!actionName) {
|
||||
return;
|
||||
}
|
||||
|
||||
await bulkActionService.addAction("_bulkAction", actionName);
|
||||
await this.refresh();
|
||||
});
|
||||
|
||||
this.$executeButton = this.$widget.find(".execute-bulk-actions");
|
||||
this.$executeButton.on("click", async () => {
|
||||
await server.post("bulk-action/execute", {
|
||||
noteIds: this.selectedOrActiveNoteIds,
|
||||
includeDescendants: this.$includeDescendants.is(":checked")
|
||||
});
|
||||
|
||||
toastService.showMessage(t("bulk_actions.bulk_actions_executed"), 3000);
|
||||
closeActiveDialog();
|
||||
});
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
this.renderAvailableActions();
|
||||
|
||||
if (!this.selectedOrActiveNoteIds) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { affectedNoteCount } = await server.post("bulk-action/affected-notes", {
|
||||
noteIds: this.selectedOrActiveNoteIds,
|
||||
includeDescendants: this.$includeDescendants.is(":checked")
|
||||
}) as { affectedNoteCount: number };
|
||||
|
||||
this.$affectedNoteCount.text(affectedNoteCount);
|
||||
|
||||
const bulkActionNote = await froca.getNote("_bulkAction");
|
||||
if (!bulkActionNote) {
|
||||
return;
|
||||
}
|
||||
|
||||
const actions = bulkActionService.parseActions(bulkActionNote);
|
||||
|
||||
this.$existingActionList.empty();
|
||||
|
||||
if (actions.length > 0) {
|
||||
this.$existingActionList.append(...actions.map((action) => action.render()).filter((action) => action !== null));
|
||||
} else {
|
||||
this.$existingActionList.append($("<p>").text(t("bulk_actions.none_yet")));
|
||||
}
|
||||
}
|
||||
|
||||
renderAvailableActions() {
|
||||
this.$availableActionList.empty();
|
||||
|
||||
for (const actionGroup of bulkActionService.ACTION_GROUPS) {
|
||||
const $actionGroupList = $("<td>");
|
||||
const $actionGroup = $("<tr>")
|
||||
.append($("<td>").text(`${actionGroup.title}: `))
|
||||
.append($actionGroupList);
|
||||
|
||||
for (const action of actionGroup.actions) {
|
||||
$actionGroupList.append($('<button class="btn btn-sm">').attr("data-action-add", action.actionName).text(action.actionTitle));
|
||||
}
|
||||
|
||||
this.$availableActionList.append($actionGroup);
|
||||
}
|
||||
}
|
||||
|
||||
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
|
||||
// only refreshing deleted attrs, otherwise components update themselves
|
||||
if (loadResults.getAttributeRows().find((row) => row.type === "label" && row.name === "action" && row.noteId === "_bulkAction" && row.isDeleted)) {
|
||||
// this may be triggered from e.g., sync without open widget, then no need to refresh the widget
|
||||
if (this.selectedOrActiveNoteIds && this.$widget.is(":visible")) {
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async openBulkActionsDialogEvent({ selectedOrActiveNoteIds }: EventData<"openBulkActionsDialog">) {
|
||||
this.selectedOrActiveNoteIds = selectedOrActiveNoteIds;
|
||||
this.$includeDescendants.prop("checked", false);
|
||||
|
||||
await this.refresh();
|
||||
openDialog(this.$widget);
|
||||
}
|
||||
}
|
143
apps/client/src/widgets/dialogs/bulk_actions.tsx
Normal file
143
apps/client/src/widgets/dialogs/bulk_actions.tsx
Normal file
@ -0,0 +1,143 @@
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import { EventData } from "../../components/app_context";
|
||||
import { closeActiveDialog, openDialog } from "../../services/dialog";
|
||||
import { t } from "../../services/i18n";
|
||||
import Modal from "../react/Modal";
|
||||
import ReactBasicWidget from "../react/ReactBasicWidget";
|
||||
import "./bulk_actions.css";
|
||||
import { BulkActionAffectedNotes } from "@triliumnext/commons";
|
||||
import server from "../../services/server";
|
||||
import FormCheckbox from "../react/FormCheckbox";
|
||||
import Button from "../react/Button";
|
||||
import bulk_action from "../../services/bulk_action";
|
||||
import toast from "../../services/toast";
|
||||
import RenameNoteBulkAction from "../bulk_actions/note/rename_note";
|
||||
import { RawHtmlBlock } from "../react/RawHtml";
|
||||
import FNote from "../../entities/fnote";
|
||||
import froca from "../../services/froca";
|
||||
|
||||
interface BulkActionProps {
|
||||
bulkActionNote?: FNote | null;
|
||||
selectedOrActiveNoteIds?: string[];
|
||||
}
|
||||
|
||||
function BulkActionComponent({ selectedOrActiveNoteIds, bulkActionNote }: BulkActionProps) {
|
||||
const [ includeDescendants, setIncludeDescendants ] = useState(false);
|
||||
const [ affectedNoteCount, setAffectedNoteCount ] = useState(0);
|
||||
const [ existingActions, setExistingActions ] = useState<RenameNoteBulkAction[]>([]);
|
||||
|
||||
if (!selectedOrActiveNoteIds || !bulkActionNote) {
|
||||
return;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
server.post<BulkActionAffectedNotes>("bulk-action/affected-notes", {
|
||||
noteIds: selectedOrActiveNoteIds,
|
||||
includeDescendants
|
||||
}).then(({ affectedNoteCount }) => setAffectedNoteCount(affectedNoteCount));
|
||||
}, [ selectedOrActiveNoteIds, includeDescendants ]);
|
||||
|
||||
// Refresh is forced by the entities reloaded event outside React.
|
||||
useEffect(() => {
|
||||
setExistingActions(bulk_action.parseActions(bulkActionNote));
|
||||
}, []);
|
||||
|
||||
return ( selectedOrActiveNoteIds &&
|
||||
<Modal
|
||||
className="bulk-actions-dialog"
|
||||
size="xl"
|
||||
title={t("bulk_actions.bulk_actions")}
|
||||
footer={<Button text={t("bulk_actions.execute_bulk_actions")} primary />}
|
||||
onSubmit={async () => {
|
||||
await server.post("bulk-action/execute", {
|
||||
noteIds: selectedOrActiveNoteIds,
|
||||
includeDescendants
|
||||
});
|
||||
|
||||
toast.showMessage(t("bulk_actions.bulk_actions_executed"), 3000);
|
||||
closeActiveDialog();
|
||||
}}
|
||||
>
|
||||
<h4>{t("bulk_actions.affected_notes")}: <span>{affectedNoteCount}</span></h4>
|
||||
<FormCheckbox
|
||||
name="include-descendants" label={t("bulk_actions.include_descendants")}
|
||||
currentValue={includeDescendants} onChange={setIncludeDescendants}
|
||||
/>
|
||||
|
||||
<h4>{t("bulk_actions.available_actions")}</h4>
|
||||
<AvailableActionsList />
|
||||
|
||||
<h4>{t("bulk_actions.chosen_actions")}</h4>
|
||||
<ExistingActionsList existingActions={existingActions} />
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function AvailableActionsList() {
|
||||
return <table class="bulk-available-action-list">
|
||||
{bulk_action.ACTION_GROUPS.map((actionGroup) => {
|
||||
return (
|
||||
<tr>
|
||||
<td>{ actionGroup.title }:</td>
|
||||
{actionGroup.actions.map(({ actionName, actionTitle }) =>
|
||||
<Button
|
||||
small text={actionTitle}
|
||||
onClick={() => bulk_action.addAction("_bulkAction", actionName)}
|
||||
/>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</table>;
|
||||
}
|
||||
|
||||
function ExistingActionsList({ existingActions }: { existingActions?: RenameNoteBulkAction[] }) {
|
||||
return (
|
||||
<table class="bulk-existing-action-list">
|
||||
{ existingActions
|
||||
? existingActions
|
||||
.map(action => {
|
||||
const renderedAction = action.render();
|
||||
if (renderedAction) {
|
||||
return <RawHtmlBlock
|
||||
html={renderedAction[0].innerHTML}
|
||||
style={{ display: "flex", alignItems: "center" }} />
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(renderedAction => renderedAction !== null)
|
||||
: <p>{t("bulk_actions.none_yet")}</p>
|
||||
}
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
export default class BulkActionsDialog extends ReactBasicWidget {
|
||||
|
||||
private props: BulkActionProps = {};
|
||||
|
||||
get component() {
|
||||
return <BulkActionComponent {...this.props} />
|
||||
}
|
||||
|
||||
async openBulkActionsDialogEvent({ selectedOrActiveNoteIds }: EventData<"openBulkActionsDialog">) {
|
||||
this.props = {
|
||||
selectedOrActiveNoteIds,
|
||||
bulkActionNote: await froca.getNote("_bulkAction")
|
||||
};
|
||||
this.doRender();
|
||||
openDialog(this.$widget);
|
||||
}
|
||||
|
||||
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
|
||||
// only refreshing deleted attrs, otherwise components update themselves
|
||||
if (loadResults.getAttributeRows().find((row) => row.type === "label" && row.name === "action" && row.noteId === "_bulkAction" && row.isDeleted)) {
|
||||
// this may be triggered from e.g., sync without open widget, then no need to refresh the widget
|
||||
if (this.props.selectedOrActiveNoteIds && this.$widget.is(":visible")) {
|
||||
this.doRender();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -1,28 +1,39 @@
|
||||
import { CSSProperties } from "preact/compat";
|
||||
|
||||
type HTMLElementLike = string | HTMLElement | JQuery<HTMLElement>;
|
||||
|
||||
interface RawHtmlProps {
|
||||
className?: string;
|
||||
html: string | HTMLElement;
|
||||
html: HTMLElementLike;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export default function RawHtml({ className, html }: RawHtmlProps) {
|
||||
export default function RawHtml({ className, html, style }: RawHtmlProps) {
|
||||
return <span
|
||||
className={className}
|
||||
dangerouslySetInnerHTML={getHtml(html)}
|
||||
style={style}
|
||||
/>;
|
||||
}
|
||||
|
||||
export function RawHtmlBlock({ className, html }: RawHtmlProps) {
|
||||
export function RawHtmlBlock({ className, html, style }: RawHtmlProps) {
|
||||
return <div
|
||||
className={className}
|
||||
dangerouslySetInnerHTML={getHtml(html)}
|
||||
style={style}
|
||||
/>
|
||||
}
|
||||
|
||||
function getHtml(html: string | HTMLElement) {
|
||||
if (typeof html !== "string") {
|
||||
function getHtml(html: string | HTMLElement | JQuery<HTMLElement>) {
|
||||
if (typeof html === "object" && "length" in html) {
|
||||
html = html[0];
|
||||
}
|
||||
|
||||
if (typeof html === "object" && "outerHTML" in html) {
|
||||
html = html.outerHTML;
|
||||
}
|
||||
|
||||
return {
|
||||
__html: html
|
||||
__html: html as string
|
||||
};
|
||||
}
|
@ -50,3 +50,7 @@ export interface RecentChangesRow {
|
||||
noteId: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
export interface BulkActionAffectedNotes {
|
||||
affectedNoteCount: number;
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user