Skip to content

[WC-2859] File uploader custom buttons #1513

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Apr 9, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/pluggableWidgets/file-uploader-web/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

- We made "Associated files/images" preconfigured with corresponding entities.

- We made it possible to configure custom buttons for file uploader entries.

### Fixed

- We fixed an issue with hover colors of the dropzone.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,20 @@ export function getProperties(
]);
}

if (values.enableCustomButtons) {
values.customButtons.forEach((_button, index) => {
hideNestedPropertiesIn(
properties,
values,
"customButtons",
index,
values.uploadMode === "files" ? ["buttonActionImage"] : ["buttonActionFile"]
);
});
} else {
hidePropertiesIn(properties, values, ["customButtons"]);
}

return properties;
}

Expand Down Expand Up @@ -81,6 +95,23 @@ export function check(values: FileUploaderPreviewProps): Problem[] {
message: "There must be at least one file per upload allowed."
});
}

if (values.enableCustomButtons) {
// check that at max one actions is default
const defaultIdx = new Set<number>();
values.customButtons.forEach((_button, index) => {
if (_button.buttonIsDefault) {
defaultIdx.add(index);
}
});

if (defaultIdx.size > 1) {
errors.push({
property: `customButtons`,
message: `Only one default button is allowed.`
});
}
}
}

return errors;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,43 @@
<caption>Object creation timeout</caption>
<description>Consider uploads unsuccessful if the Action to create new files/images does not create new objects within the configured amount of seconds.</description>
</property>
<property key="enableCustomButtons" type="boolean" defaultValue="false">
<caption>Enable custom buttons</caption>
<description />
</property>
<property key="customButtons" type="object" isList="true" required="false">
<caption>Custom buttons</caption>
<description />
<properties>
<propertyGroup caption="General">
<property key="buttonCaption" type="textTemplate">
<caption>Caption</caption>
<description />
</property>
<property key="buttonIcon" type="icon">
<caption>Icon</caption>
<description />
</property>
<property key="buttonActionFile" type="action" dataSource="../associatedFiles">
<caption>Action</caption>
<description />
</property>
<property key="buttonActionImage" type="action" dataSource="../associatedImages">
<caption>Action</caption>
<description />
</property>
<property key="buttonIsDefault" type="boolean" defaultValue="false">
<caption>Is default</caption>
<description>The action will be triggered by clicking on the file entry.</description>
</property>
<property key="buttonIsVisible" type="expression" defaultValue="true">
<caption>Is visible</caption>
<description>The button will be hidden if false is returned.</description>
<returnType type="Boolean" />
</property>
</propertyGroup>
</properties>
</property>
</propertyGroup>
</properties>
</widget>
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { createElement, MouseEvent, ReactElement, useCallback } from "react";
import classNames from "classnames";
import { ListActionValue } from "mendix";
import { FileStore } from "../stores/FileStore";

interface ActionButtonProps {
icon: ReactElement;
title?: string;
action?: () => void;
isDisabled: boolean;
}

export function ActionButton({ action, icon, title, isDisabled }: ActionButtonProps) {
const onClick = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
action?.();
},
[action]
);
return (
<button
role={"button"}
className={classNames("action-button", {
disabled: isDisabled
})}
onClick={onClick}
title={title}
>
{icon}
</button>
);
}

interface FileActionButtonProps {
store: FileStore;
listAction?: ListActionValue;
title?: string;
icon: ReactElement;
}

export function FileActionButton({ listAction, store, title, icon }: FileActionButtonProps) {
const action = useCallback(() => {
store.executeAction(listAction);
}, [store, listAction]);

return (
<ActionButton
icon={icon}
title={title}
action={action}
isDisabled={!(!listAction || store.canExecute(listAction))}
/>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { createElement, ReactElement, useCallback } from "react";
import { FileUploaderContainerProps } from "../../typings/FileUploaderProps";
import { ActionButton, FileActionButton } from "./ActionButton";
import { IconInternal } from "@mendix/widget-plugin-component-kit/IconInternal";
import { FileStore } from "../stores/FileStore";
import { useTranslationsStore } from "../utils/useTranslationsStore";

interface ButtonsBarProps {
actions?: FileUploaderContainerProps["customButtons"];
store: FileStore;
}

export const ActionsBar = ({ actions, store }: ButtonsBarProps): ReactElement | null => {
if (!actions) {
return <DefaultActionsBar store={store} />;
}

if (actions && store.canExecuteActions) {
return (
<div className={"entry-details-actions"}>
{actions.map(a => {
if (!a.buttonIsVisible.value) {
return null;
}
const listAction = a.buttonActionImage ?? a.buttonActionFile;

return (
<FileActionButton
icon={<IconInternal icon={a.buttonIcon.value} className={"file-action-icon"} />}
title={a.buttonCaption.value}
store={store}
listAction={listAction}
/>
);
})}
</div>
);
}

return null;
};

function DefaultActionsBar(props: ButtonsBarProps) {
const translations = useTranslationsStore();

const onRemove = useCallback(() => {
props.store.remove();
}, [props.store]);

const onViewClick = useCallback(() => {
onDownloadClick(props.store.downloadUrl);
}, [props.store.downloadUrl]);

return (
<div className={"entry-details-actions"}>
<ActionButton
icon={<div className={"download-icon"} />}
title={translations.get("downloadButtonTextMessage")}
action={onViewClick}
isDisabled={!props.store.canDownload}
/>
<ActionButton
icon={<div className={"remove-icon"} />}
title={translations.get("removeButtonTextMessage")}
action={onRemove}
isDisabled={!props.store.canRemove}
/>
</div>
);
}

function onDownloadClick(fileUrl: string | undefined): void {
if (!fileUrl) {
return;
}
const url = new URL(fileUrl);
url.searchParams.append("target", "window");

window.open(url, "mendix_file");
}
Original file line number Diff line number Diff line change
@@ -1,21 +1,33 @@
import classNames from "classnames";
import { ProgressBar } from "./ProgressBar";
import { UploadInfo } from "./UploadInfo";
import { createElement, ReactElement, useCallback, MouseEvent, KeyboardEvent } from "react";
import { createElement, ReactElement, useCallback, MouseEvent, KeyboardEvent, ReactNode } from "react";
import { FileStatus, FileStore } from "../stores/FileStore";
import { observer } from "mobx-react-lite";
import fileSize from "filesize.js";
import { FileIcon } from "./FileIcon";
import { useTranslationsStore } from "../utils/useTranslationsStore";
import { FileUploaderContainerProps } from "../../typings/FileUploaderProps";
import { ActionsBar } from "./ActionsBar";

interface FileEntryContainerProps {
store: FileStore;
actions?: FileUploaderContainerProps["customButtons"];
}

export const FileEntryContainer = observer(({ store }: FileEntryContainerProps): ReactElement => {
const onRemove = useCallback(() => {
store.remove();
}, [store]);
export const FileEntryContainer = observer(({ store, actions }: FileEntryContainerProps): ReactElement | null => {
const defaultAction = actions?.find(a => {
return a.buttonIsDefault;
});

const defaultListAction = defaultAction?.buttonActionFile ?? defaultAction?.buttonActionImage;

const onDefaultAction = useCallback(() => {
store.executeAction(defaultListAction);
}, [store, defaultListAction]);

if (store.fileStatus === "missing") {
return null;
}

return (
<FileEntry
Expand All @@ -25,10 +37,8 @@ export const FileEntryContainer = observer(({ store }: FileEntryContainerProps):
mimeType={store.mimeType}
fileStatus={store.fileStatus}
errorMessage={store.errorDescription}
canRemove={store.canRemove}
onRemove={onRemove}
canDownload={store.canDownload}
downloadUrl={store.downloadUrl}
defaultAction={defaultListAction && store.canExecute(defaultListAction) ? onDefaultAction : undefined}
actions={<ActionsBar actions={actions} store={store} />}
/>
);
});
Expand All @@ -42,44 +52,32 @@ interface FileEntryProps {
fileStatus: FileStatus;
errorMessage?: string;

canRemove: boolean;
onRemove: () => void;
defaultAction?: () => void;

canDownload: boolean;
downloadUrl?: string;
actions?: ReactNode;
}

function FileEntry(props: FileEntryProps): ReactElement {
const translations = useTranslationsStore();
const { defaultAction } = props;

const { canDownload, downloadUrl, onRemove } = props;

const onViewClick = useCallback(
const onClick = useCallback(
(e: MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
e.preventDefault();
onDownloadClick(downloadUrl);
defaultAction?.();
},
[downloadUrl]
[defaultAction]
);

const onKeyDown = useCallback(
(e: KeyboardEvent<HTMLDivElement>) => {
if (e.code === "Enter" || e.code === "Space") {
e.stopPropagation();
e.preventDefault();
onDownloadClick(downloadUrl);
defaultAction?.();
}
},
[downloadUrl]
);

const onRemoveClick = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
onRemove();
},
[onRemove]
[defaultAction]
);

return (
Expand All @@ -89,9 +87,9 @@ function FileEntry(props: FileEntryProps): ReactElement {
invalid: props.fileStatus === "validationError"
})}
title={props.title}
tabIndex={canDownload ? 0 : undefined}
onClick={canDownload ? onViewClick : undefined}
onKeyDown={canDownload ? onKeyDown : undefined}
tabIndex={!!defaultAction ? 0 : undefined}
onClick={!!defaultAction ? onClick : undefined}
onKeyDown={!!defaultAction ? onKeyDown : undefined}
>
<div className={"entry-details"}>
<div
Expand All @@ -111,19 +109,7 @@ function FileEntry(props: FileEntryProps): ReactElement {
<div className={"entry-details-main-size"}>{props.size !== -1 && fileSize(props.size)}</div>
</div>

<div className={"entry-details-actions"}>
{downloadUrl && <div className={"download-icon"} />}
<button
className={classNames("action-button", {
disabled: !props.canRemove
})}
onClick={onRemoveClick}
role={"button"}
title={translations.get("removeButtonTextMessage")}
>
<div className={"remove-icon"} />
</button>
</div>
{props.actions}
</div>
<div className={"entry-progress"}>
<ProgressBar visible={props.fileStatus === "uploading"} indeterminate />
Expand All @@ -134,11 +120,3 @@ function FileEntry(props: FileEntryProps): ReactElement {
</div>
);
}

function onDownloadClick(fileUrl: string | undefined): void {
if (!fileUrl) {
return;
}
const url = `${fileUrl}&target=window`;
window.open(url, "mendix_file");
}
Loading