Skip to content

Commit d0df74c

Browse files
Andrea RosciAndrea Rosci
authored andcommitted
Add Tag, TagManagementDialog and SimpleConfirmationDialog components
Change-type: minor
1 parent 2102c77 commit d0df74c

File tree

17 files changed

+1478
-4
lines changed

17 files changed

+1478
-4
lines changed

src/components/Form/Widgets/FileWidget.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ export const FileWidget = ({
138138
const mobile = useMediaQuery(theme.breakpoints.down('md'));
139139
const formId = useRandomUUID();
140140

141-
const fileUploadId = useMemo(() => `file-upload-${formId}`, []);
141+
const fileUploadId = useMemo(() => `file-upload-${formId}`, [formId]);
142142

143143
const options = uiSchema?.['ui:options'] as UIOptions | undefined;
144144
const accept = options?.accept;

src/components/Map/index.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,5 +206,4 @@ export interface MapProps<T> extends React.HTMLAttributes<HTMLElement> {
206206
mapClick?: (e: google.maps.MapMouseEvent) => void;
207207
}
208208

209-
/** [View story source](https://github.com/balena-io-modules/rendition/blob/master/src/components/Map/Map.stories.tsx) */
210209
export const Map = BaseMap;
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import React from 'react';
2+
import type { Meta, StoryObj } from '@storybook/react';
3+
import type { SimpleConfirmationDialogProps } from '.';
4+
import { SimpleConfirmationDialog } from '.';
5+
import { Button } from '@mui/material';
6+
7+
const SimpleConfirmationDialogTemplate = (
8+
props: Omit<SimpleConfirmationDialogProps, 'onClose'>,
9+
) => {
10+
const [demoShow, setDemoShow] = React.useState(false);
11+
return (
12+
<>
13+
<Button
14+
onClick={() => {
15+
setDemoShow(!demoShow);
16+
}}
17+
>
18+
Show
19+
</Button>
20+
{demoShow && (
21+
<SimpleConfirmationDialog
22+
{...props}
23+
onClose={() => {
24+
setDemoShow(false);
25+
}}
26+
/>
27+
)}
28+
</>
29+
);
30+
};
31+
32+
const meta = {
33+
title: 'Other/SimpleConfirmationDialog',
34+
component: SimpleConfirmationDialogTemplate,
35+
tags: ['autodocs'],
36+
} satisfies Meta<typeof SimpleConfirmationDialogTemplate>;
37+
38+
export default meta;
39+
type Story = StoryObj<typeof meta>;
40+
41+
export const Default: Story = {
42+
args: {
43+
title: 'Simple confirmation dialog demo',
44+
action: 'Demo',
45+
},
46+
};
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import * as React from 'react';
2+
import { useTranslation } from '../../hooks/useTranslations';
3+
import {
4+
Dialog,
5+
DialogActions,
6+
DialogContent,
7+
DialogTitle,
8+
} from '@mui/material';
9+
import { Spinner } from '../Spinner';
10+
import { ButtonWithTracking } from '../ButtonWithTracking';
11+
12+
export interface SimpleConfirmationDialogProps {
13+
children?: React.ReactNode;
14+
title: string | JSX.Element;
15+
cancel?: string;
16+
action: string;
17+
onClose: (confirmed: boolean) => MaybePromise<void>;
18+
danger?: boolean;
19+
}
20+
21+
export const SimpleConfirmationDialog = ({
22+
title,
23+
onClose,
24+
cancel,
25+
action,
26+
children,
27+
danger,
28+
}: SimpleConfirmationDialogProps) => {
29+
const { t } = useTranslation();
30+
const [isSubmitting, setIsSubmitting] = React.useState(false);
31+
return (
32+
<Dialog
33+
onClose={async () => {
34+
await onClose(false);
35+
}}
36+
open
37+
>
38+
<Spinner show={isSubmitting}>
39+
<DialogTitle>{title ?? t('actions_messages.confirmation')}</DialogTitle>
40+
<DialogContent>{children}</DialogContent>
41+
<DialogActions>
42+
<ButtonWithTracking
43+
eventName="Confirmation dialog cancel click"
44+
aria-label={t('aria_labels.execute_action', {
45+
actionName: cancel ?? t('actions.cancel'),
46+
})}
47+
onClick={async () => {
48+
await onClose(false);
49+
}}
50+
variant="outlined"
51+
color="secondary"
52+
autoFocus={danger}
53+
>
54+
{cancel ?? t('actions.cancel')}
55+
</ButtonWithTracking>
56+
<ButtonWithTracking
57+
eventName={`Confirmation dialog ${action} click`}
58+
aria-label={t('aria_labels.execute_action', { actionName: action })}
59+
onClick={async () => {
60+
try {
61+
const result = onClose(true);
62+
if (result != null && result instanceof Promise) {
63+
setIsSubmitting(true);
64+
}
65+
await result;
66+
} finally {
67+
setIsSubmitting(false);
68+
}
69+
}}
70+
variant="contained"
71+
color={danger ? 'error' : 'primary'}
72+
autoFocus={!danger}
73+
>
74+
{action}
75+
</ButtonWithTracking>
76+
</DialogActions>
77+
</Spinner>
78+
</Dialog>
79+
);
80+
};

src/components/Tag/Tag.stories.tsx

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import type { Meta, StoryObj } from '@storybook/react';
2+
import { Tag } from '.';
3+
4+
const meta = {
5+
title: 'Other/Tag',
6+
component: Tag,
7+
tags: ['autodocs'],
8+
} satisfies Meta<typeof Tag>;
9+
10+
export default meta;
11+
type Story = StoryObj<typeof meta>;
12+
13+
export const Default: Story = {
14+
args: {
15+
name: 'Tag',
16+
value: 'Value',
17+
},
18+
};
19+
20+
export const OnlyValue: Story = {
21+
args: {
22+
value: 'Value',
23+
},
24+
};
25+
26+
export const OnlyName: Story = {
27+
args: {
28+
value: 'Name',
29+
},
30+
};
31+
32+
export const Empty: Story = { args: {} };
33+
34+
export const MultipleValues: Story = {
35+
args: {
36+
multiple: [
37+
{ name: 'Tag1', operator: 'contains', value: 'value1' },
38+
{
39+
prefix: 'or',
40+
name: 'Tag2',
41+
operator: 'contains',
42+
value: 'value2',
43+
},
44+
],
45+
},
46+
};
47+
48+
export const Overflow: Story = {
49+
args: {
50+
multiple: [
51+
{ name: 'Tag1', operator: 'contains', value: 'value1' },
52+
{
53+
prefix: 'or',
54+
name: 'Tag2',
55+
operator: 'contains',
56+
value: 'value2',
57+
},
58+
{
59+
prefix: 'or',
60+
name: 'Tag3',
61+
operator: 'contains',
62+
value: 'value3',
63+
},
64+
{
65+
prefix: 'or',
66+
name: 'Tag4',
67+
operator: 'contains',
68+
value: 'value4',
69+
},
70+
],
71+
},
72+
};

src/components/Tag/index.tsx

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import React from 'react';
2+
import { faTimes } from '@fortawesome/free-solid-svg-icons/faTimes';
3+
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
4+
import { Box, type BoxProps, Button, Tooltip, Typography } from '@mui/material';
5+
6+
// Prevent the tags taking up too much horizontal space
7+
const MAX_ITEMS_TO_DISPLAY = 3;
8+
9+
const tagItemsToString = (items: TagItem[]) => {
10+
return items
11+
.map((item, index) => {
12+
const prefix = index > 0 ? `${item.prefix ?? ','} ` : '';
13+
const separator = item.operator ? ` ${item.operator} ` : ': ';
14+
return (
15+
prefix +
16+
(item.value ? `${item.name}${separator}${item.value}` : item.name)
17+
);
18+
})
19+
.join('\n');
20+
};
21+
22+
export interface TagProps extends Omit<BoxProps, 'onClick'> {
23+
/** The value part of the tag */
24+
value?: string;
25+
/** The name part of the tag, if not provided, only the value will be shown */
26+
name?: string;
27+
/** The operator that goes between the name and value of the tag */
28+
operator?: string;
29+
/** An array of name-value pairs, with an optional delimiter to be used between the previous and current tag entry */
30+
multiple?: TagItem[];
31+
/** Callback method, that if passed, a "close" button will be added to the right-hand side of the tag */
32+
onClose?: (event: React.MouseEvent<HTMLElement>) => void;
33+
/** Callback method, that if passed, the tag will become clickable */
34+
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
35+
}
36+
37+
export const Tag = ({
38+
name,
39+
operator,
40+
value,
41+
multiple,
42+
onClose,
43+
onClick,
44+
...props
45+
}: TagProps) => {
46+
let tagArray = multiple ?? [{ name, operator, value }];
47+
48+
if (!tagArray.length) {
49+
return null;
50+
}
51+
const overflow = tagArray.length > MAX_ITEMS_TO_DISPLAY;
52+
53+
if (overflow) {
54+
tagArray = [tagArray[0], { name: `... and ${tagArray.length - 1} more` }];
55+
}
56+
57+
const tagContent = (
58+
<Tooltip title={overflow ? tagItemsToString(multiple ?? []) : undefined}>
59+
<Box
60+
sx={(theme) => ({
61+
backgroundColor: theme.palette.blue.light,
62+
border: `1px solid ${theme.palette.info.main}`,
63+
borderRadius: '2px',
64+
lineHeight: 1.5,
65+
width: 'fit-content',
66+
py: 1,
67+
px: 2,
68+
})}
69+
>
70+
{tagArray.map((tagEntry, index) => {
71+
const nameValueSeparator = tagEntry.operator
72+
? ` ${tagEntry.operator} `
73+
: ': ';
74+
75+
return (
76+
<React.Fragment key={index}>
77+
{index > 0 && !overflow && (
78+
<Typography
79+
sx={{
80+
whitespace: 'pre',
81+
fontStyle: 'italic',
82+
color: 'palette.primary',
83+
}}
84+
>{` ${tagEntry.prefix ?? ','} `}</Typography>
85+
)}
86+
87+
{!tagEntry.value && !tagEntry.name && (
88+
<Typography
89+
sx={{
90+
fontStyle: 'italic',
91+
color: 'palette.primary',
92+
}}
93+
>
94+
no value
95+
</Typography>
96+
)}
97+
98+
{tagEntry.name && (
99+
<Typography
100+
sx={{
101+
whitespace: 'pre',
102+
color: 'palette.primary',
103+
}}
104+
>
105+
{`${tagEntry.name}${tagEntry.value ? nameValueSeparator : ''}`}
106+
</Typography>
107+
)}
108+
109+
{tagEntry.value && (
110+
<Typography
111+
sx={{
112+
fontWeight: 'bold',
113+
color: 'palette.primary',
114+
}}
115+
>
116+
{tagEntry.value}
117+
</Typography>
118+
)}
119+
</React.Fragment>
120+
);
121+
})}
122+
</Box>
123+
</Tooltip>
124+
);
125+
126+
return (
127+
<Box {...props}>
128+
{onClick ? <Button onClick={onClick}>{tagContent}</Button> : tagContent}
129+
{onClose && (
130+
<Button
131+
sx={(theme) => ({
132+
py: 1,
133+
pl: 2,
134+
pr: 3,
135+
fontSize: 1,
136+
color: theme.palette.customGrey.main,
137+
})}
138+
onClick={onClose}
139+
>
140+
<FontAwesomeIcon icon={faTimes} />
141+
</Button>
142+
)}
143+
</Box>
144+
);
145+
};
146+
147+
export interface TagItem {
148+
value?: string;
149+
name?: string;
150+
operator?: string;
151+
prefix?: string;
152+
}

0 commit comments

Comments
 (0)