Skip to content

Commit 823aca0

Browse files
committed
feat: make custom buttons possible
1 parent 95203f9 commit 823aca0

13 files changed

+494
-87
lines changed

Diff for: packages/pluggableWidgets/file-uploader-web/CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
1010

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

13+
- We made it possible to configure custom buttons for file uploader entries.
14+
1315
### Fixed
1416

1517
- We fixed an issue with hover colors of the dropzone.

Diff for: packages/pluggableWidgets/file-uploader-web/src/FileUploader.editorConfig.ts

+31
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,20 @@ export function getProperties(
2626
]);
2727
}
2828

29+
if (values.enableCustomButtons) {
30+
values.customButtons.forEach((_button, index) => {
31+
hideNestedPropertiesIn(
32+
properties,
33+
values,
34+
"customButtons",
35+
index,
36+
values.uploadMode === "files" ? ["buttonActionImage"] : ["buttonActionFile"]
37+
);
38+
});
39+
} else {
40+
hidePropertiesIn(properties, values, ["customButtons"]);
41+
}
42+
2943
return properties;
3044
}
3145

@@ -81,6 +95,23 @@ export function check(values: FileUploaderPreviewProps): Problem[] {
8195
message: "There must be at least one file per upload allowed."
8296
});
8397
}
98+
99+
if (values.enableCustomButtons) {
100+
// check that at max one actions is default
101+
const defaultIdx = new Set<number>();
102+
values.customButtons.forEach((_button, index) => {
103+
if (_button.buttonIsDefault) {
104+
defaultIdx.add(index);
105+
}
106+
});
107+
108+
if (defaultIdx.size > 1) {
109+
errors.push({
110+
property: `customButtons`,
111+
message: `Only one default button is allowed.`
112+
});
113+
}
114+
}
84115
}
85116

86117
return errors;

Diff for: packages/pluggableWidgets/file-uploader-web/src/FileUploader.xml

+37
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,43 @@
194194
<caption>Object creation timeout</caption>
195195
<description>Consider uploads unsuccessful if the Action to create new files/images does not create new objects within the configured amount of seconds.</description>
196196
</property>
197+
<property key="enableCustomButtons" type="boolean" defaultValue="false">
198+
<caption>Enable custom buttons</caption>
199+
<description />
200+
</property>
201+
<property key="customButtons" type="object" isList="true" required="false">
202+
<caption>Custom buttons</caption>
203+
<description />
204+
<properties>
205+
<propertyGroup caption="General">
206+
<property key="buttonCaption" type="textTemplate">
207+
<caption>Caption</caption>
208+
<description />
209+
</property>
210+
<property key="buttonIcon" type="icon">
211+
<caption>Icon</caption>
212+
<description />
213+
</property>
214+
<property key="buttonActionFile" type="action" dataSource="../associatedFiles">
215+
<caption>Action</caption>
216+
<description />
217+
</property>
218+
<property key="buttonActionImage" type="action" dataSource="../associatedImages">
219+
<caption>Action</caption>
220+
<description />
221+
</property>
222+
<property key="buttonIsDefault" type="boolean" defaultValue="false">
223+
<caption>Is default</caption>
224+
<description>The action will be triggered by clicking on the file entry.</description>
225+
</property>
226+
<property key="buttonIsVisible" type="expression" defaultValue="true">
227+
<caption>Is visible</caption>
228+
<description>The button will be hidden if false is returned.</description>
229+
<returnType type="Boolean" />
230+
</property>
231+
</propertyGroup>
232+
</properties>
233+
</property>
197234
</propertyGroup>
198235
</properties>
199236
</widget>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { createElement, MouseEvent, ReactElement, useCallback } from "react";
2+
import classNames from "classnames";
3+
import { ListActionValue } from "mendix";
4+
import { FileStore } from "../stores/FileStore";
5+
6+
interface ActionButtonProps {
7+
icon: ReactElement;
8+
title?: string;
9+
action?: () => void;
10+
isDisabled: boolean;
11+
}
12+
13+
export function ActionButton({ action, icon, title, isDisabled }: ActionButtonProps) {
14+
const onClick = useCallback(
15+
(e: MouseEvent<HTMLButtonElement>) => {
16+
e.stopPropagation();
17+
action?.();
18+
},
19+
[action]
20+
);
21+
return (
22+
<button
23+
role={"button"}
24+
className={classNames("action-button", {
25+
disabled: isDisabled
26+
})}
27+
onClick={onClick}
28+
title={title}
29+
>
30+
{icon}
31+
</button>
32+
);
33+
}
34+
35+
interface FileActionButtonProps {
36+
store: FileStore;
37+
listAction?: ListActionValue;
38+
title?: string;
39+
icon: ReactElement;
40+
}
41+
42+
export function FileActionButton({ listAction, store, title, icon }: FileActionButtonProps) {
43+
const action = useCallback(() => {
44+
store.executeAction(listAction);
45+
}, [store, listAction]);
46+
47+
return (
48+
<ActionButton
49+
icon={icon}
50+
title={title}
51+
action={action}
52+
isDisabled={!(!listAction || store.canExecute(listAction))}
53+
/>
54+
);
55+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { createElement, ReactElement, useCallback } from "react";
2+
import { FileUploaderContainerProps } from "../../typings/FileUploaderProps";
3+
import { ActionButton, FileActionButton } from "./ActionButton";
4+
import { IconInternal } from "@mendix/widget-plugin-component-kit/IconInternal";
5+
import { FileStore } from "../stores/FileStore";
6+
import { useTranslationsStore } from "../utils/useTranslationsStore";
7+
8+
interface ButtonsBarProps {
9+
actions?: FileUploaderContainerProps["customButtons"];
10+
store: FileStore;
11+
}
12+
13+
export const ActionsBar = ({ actions, store }: ButtonsBarProps): ReactElement | null => {
14+
if (!actions) {
15+
return <DefaultActionsBar store={store} />;
16+
}
17+
18+
if (actions && store.canExecuteActions) {
19+
return (
20+
<div className={"entry-details-actions"}>
21+
{actions.map(a => {
22+
if (!a.buttonIsVisible.value) {
23+
return null;
24+
}
25+
const listAction = a.buttonActionImage ?? a.buttonActionFile;
26+
27+
return (
28+
<FileActionButton
29+
icon={<IconInternal icon={a.buttonIcon.value} className={"file-action-icon"} />}
30+
title={a.buttonCaption.value}
31+
store={store}
32+
listAction={listAction}
33+
/>
34+
);
35+
})}
36+
</div>
37+
);
38+
}
39+
40+
return null;
41+
};
42+
43+
function DefaultActionsBar(props: ButtonsBarProps) {
44+
const translations = useTranslationsStore();
45+
46+
const onRemove = useCallback(() => {
47+
props.store.remove();
48+
}, [props.store]);
49+
50+
const onViewClick = useCallback(() => {
51+
onDownloadClick(props.store.downloadUrl);
52+
}, [props.store.downloadUrl]);
53+
54+
return (
55+
<div className={"entry-details-actions"}>
56+
<ActionButton
57+
icon={<div className={"download-icon"} />}
58+
title={translations.get("downloadButtonTextMessage")}
59+
action={onViewClick}
60+
isDisabled={!props.store.canDownload}
61+
/>
62+
<ActionButton
63+
icon={<div className={"remove-icon"} />}
64+
title={translations.get("removeButtonTextMessage")}
65+
action={onRemove}
66+
isDisabled={!props.store.canRemove}
67+
/>
68+
</div>
69+
);
70+
}
71+
72+
function onDownloadClick(fileUrl: string | undefined): void {
73+
if (!fileUrl) {
74+
return;
75+
}
76+
const url = `${fileUrl}&target=window`;
77+
window.open(url, "mendix_file");
78+
}
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,33 @@
11
import classNames from "classnames";
22
import { ProgressBar } from "./ProgressBar";
33
import { UploadInfo } from "./UploadInfo";
4-
import { createElement, ReactElement, useCallback, MouseEvent, KeyboardEvent } from "react";
4+
import { createElement, ReactElement, useCallback, MouseEvent, KeyboardEvent, ReactNode } from "react";
55
import { FileStatus, FileStore } from "../stores/FileStore";
66
import { observer } from "mobx-react-lite";
77
import fileSize from "filesize.js";
88
import { FileIcon } from "./FileIcon";
9-
import { useTranslationsStore } from "../utils/useTranslationsStore";
9+
import { FileUploaderContainerProps } from "../../typings/FileUploaderProps";
10+
import { ActionsBar } from "./ActionsBar";
1011

1112
interface FileEntryContainerProps {
1213
store: FileStore;
14+
actions?: FileUploaderContainerProps["customButtons"];
1315
}
1416

15-
export const FileEntryContainer = observer(({ store }: FileEntryContainerProps): ReactElement => {
16-
const onRemove = useCallback(() => {
17-
store.remove();
18-
}, [store]);
17+
export const FileEntryContainer = observer(({ store, actions }: FileEntryContainerProps): ReactElement | null => {
18+
const defaultAction = actions?.find(a => {
19+
return a.buttonIsDefault;
20+
});
21+
22+
const defaultListAction = defaultAction?.buttonActionFile ?? defaultAction?.buttonActionImage;
23+
24+
const onDefaultAction = useCallback(() => {
25+
store.executeAction(defaultListAction);
26+
}, [store, defaultListAction]);
27+
28+
if (store.fileStatus === "missing") {
29+
return null;
30+
}
1931

2032
return (
2133
<FileEntry
@@ -25,10 +37,8 @@ export const FileEntryContainer = observer(({ store }: FileEntryContainerProps):
2537
mimeType={store.mimeType}
2638
fileStatus={store.fileStatus}
2739
errorMessage={store.errorDescription}
28-
canRemove={store.canRemove}
29-
onRemove={onRemove}
30-
canDownload={store.canDownload}
31-
downloadUrl={store.downloadUrl}
40+
defaultAction={defaultListAction && store.canExecute(defaultListAction) ? onDefaultAction : undefined}
41+
actions={<ActionsBar actions={actions} store={store} />}
3242
/>
3343
);
3444
});
@@ -42,44 +52,32 @@ interface FileEntryProps {
4252
fileStatus: FileStatus;
4353
errorMessage?: string;
4454

45-
canRemove: boolean;
46-
onRemove: () => void;
55+
defaultAction?: () => void;
4756

48-
canDownload: boolean;
49-
downloadUrl?: string;
57+
actions?: ReactNode;
5058
}
5159

5260
function FileEntry(props: FileEntryProps): ReactElement {
53-
const translations = useTranslationsStore();
61+
const { defaultAction } = props;
5462

55-
const { canDownload, downloadUrl, onRemove } = props;
56-
57-
const onViewClick = useCallback(
63+
const onClick = useCallback(
5864
(e: MouseEvent<HTMLDivElement>) => {
5965
e.stopPropagation();
6066
e.preventDefault();
61-
onDownloadClick(downloadUrl);
67+
defaultAction?.();
6268
},
63-
[downloadUrl]
69+
[defaultAction]
6470
);
6571

6672
const onKeyDown = useCallback(
6773
(e: KeyboardEvent<HTMLDivElement>) => {
6874
if (e.code === "Enter" || e.code === "Space") {
6975
e.stopPropagation();
7076
e.preventDefault();
71-
onDownloadClick(downloadUrl);
77+
defaultAction?.();
7278
}
7379
},
74-
[downloadUrl]
75-
);
76-
77-
const onRemoveClick = useCallback(
78-
(e: MouseEvent<HTMLButtonElement>) => {
79-
e.stopPropagation();
80-
onRemove();
81-
},
82-
[onRemove]
80+
[defaultAction]
8381
);
8482

8583
return (
@@ -89,9 +87,9 @@ function FileEntry(props: FileEntryProps): ReactElement {
8987
invalid: props.fileStatus === "validationError"
9088
})}
9189
title={props.title}
92-
tabIndex={canDownload ? 0 : undefined}
93-
onClick={canDownload ? onViewClick : undefined}
94-
onKeyDown={canDownload ? onKeyDown : undefined}
90+
tabIndex={!!defaultAction ? 0 : undefined}
91+
onClick={!!defaultAction ? onClick : undefined}
92+
onKeyDown={!!defaultAction ? onKeyDown : undefined}
9593
>
9694
<div className={"entry-details"}>
9795
<div
@@ -111,19 +109,7 @@ function FileEntry(props: FileEntryProps): ReactElement {
111109
<div className={"entry-details-main-size"}>{props.size !== -1 && fileSize(props.size)}</div>
112110
</div>
113111

114-
<div className={"entry-details-actions"}>
115-
{downloadUrl && <div className={"download-icon"} />}
116-
<button
117-
className={classNames("action-button", {
118-
disabled: !props.canRemove
119-
})}
120-
onClick={onRemoveClick}
121-
role={"button"}
122-
title={translations.get("removeButtonTextMessage")}
123-
>
124-
<div className={"remove-icon"} />
125-
</button>
126-
</div>
112+
{props.actions}
127113
</div>
128114
<div className={"entry-progress"}>
129115
<ProgressBar visible={props.fileStatus === "uploading"} indeterminate />
@@ -134,11 +120,3 @@ function FileEntry(props: FileEntryProps): ReactElement {
134120
</div>
135121
);
136122
}
137-
138-
function onDownloadClick(fileUrl: string | undefined): void {
139-
if (!fileUrl) {
140-
return;
141-
}
142-
const url = `${fileUrl}&target=window`;
143-
window.open(url, "mendix_file");
144-
}

Diff for: packages/pluggableWidgets/file-uploader-web/src/components/FileUploaderRoot.tsx

+7-1
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,13 @@ export const FileUploaderRoot = observer((props: FileUploaderContainerProps): Re
3535

3636
<div className={"files-list"}>
3737
{(rootStore.files ?? []).map(fileStore => {
38-
return <FileEntryContainer store={fileStore} key={fileStore.key} />;
38+
return (
39+
<FileEntryContainer
40+
store={fileStore}
41+
key={fileStore.key}
42+
actions={props.enableCustomButtons ? props.customButtons : undefined}
43+
/>
44+
);
3945
})}
4046
</div>
4147
</div>

0 commit comments

Comments
 (0)