mirror of
https://github.com/zadam/trilium.git
synced 2025-10-21 07:38:53 +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 {
|
interface RawHtmlProps {
|
||||||
className?: string;
|
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
|
return <span
|
||||||
className={className}
|
className={className}
|
||||||
dangerouslySetInnerHTML={getHtml(html)}
|
dangerouslySetInnerHTML={getHtml(html)}
|
||||||
|
style={style}
|
||||||
/>;
|
/>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RawHtmlBlock({ className, html }: RawHtmlProps) {
|
export function RawHtmlBlock({ className, html, style }: RawHtmlProps) {
|
||||||
return <div
|
return <div
|
||||||
className={className}
|
className={className}
|
||||||
dangerouslySetInnerHTML={getHtml(html)}
|
dangerouslySetInnerHTML={getHtml(html)}
|
||||||
|
style={style}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|
||||||
function getHtml(html: string | HTMLElement) {
|
function getHtml(html: string | HTMLElement | JQuery<HTMLElement>) {
|
||||||
if (typeof html !== "string") {
|
if (typeof html === "object" && "length" in html) {
|
||||||
|
html = html[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof html === "object" && "outerHTML" in html) {
|
||||||
html = html.outerHTML;
|
html = html.outerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
__html: html
|
__html: html as string
|
||||||
};
|
};
|
||||||
}
|
}
|
@ -50,3 +50,7 @@ export interface RecentChangesRow {
|
|||||||
noteId: string;
|
noteId: string;
|
||||||
date: string;
|
date: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BulkActionAffectedNotes {
|
||||||
|
affectedNoteCount: number;
|
||||||
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user