mirror of
https://github.com/umami-software/umami.git
synced 2026-02-04 04:37:11 +01:00
Feat/um 197 hook up teams (#1825)
* Link up teams UI. * Fix auth order. * PR touchups.
This commit is contained in:
parent
f908476e71
commit
8a9532f213
17 changed files with 500 additions and 111 deletions
|
|
@ -46,6 +46,7 @@ export const labels = defineMessages({
|
|||
deleteWebsite: { id: 'label.delete-website', defaultMessage: 'Delete website' },
|
||||
reset: { id: 'label.reset', defaultMessage: 'Reset' },
|
||||
addWebsite: { id: 'label.add-website', defaultMessage: 'Add website' },
|
||||
addWebsites: { id: 'label.add-websites', defaultMessage: 'Add websites' },
|
||||
changePassword: { id: 'label.change-password', defaultMessage: 'Change password' },
|
||||
currentPassword: { id: 'label.current-password', defaultMessage: 'Current password' },
|
||||
newPassword: { id: 'label.new-password', defaultMessage: 'New password' },
|
||||
|
|
@ -145,6 +146,10 @@ export const messages = defineMessages({
|
|||
id: 'message.reset-website',
|
||||
defaultMessage: 'To reset this website, type {confirmation} in the box below to confirm.',
|
||||
},
|
||||
websitesShared: {
|
||||
id: 'message.shared-website',
|
||||
defaultMessage: 'Websites can be viewed by the entire team.',
|
||||
},
|
||||
invalidDomain: {
|
||||
id: 'message.invalid-domain',
|
||||
defaultMessage: 'Invalid domain. Do not include http/https.',
|
||||
|
|
@ -162,6 +167,14 @@ export const messages = defineMessages({
|
|||
id: 'messages.no-websites',
|
||||
defaultMessage: 'You do not have any websites configured.',
|
||||
},
|
||||
noTeamWebsites: {
|
||||
id: 'messages.no-team-websites',
|
||||
defaultMessage: 'This team does not have any websites.',
|
||||
},
|
||||
websitesAreShared: {
|
||||
id: 'messages.websites-are-shared',
|
||||
defaultMessage: 'Websites can be viewed by anyone on the team.',
|
||||
},
|
||||
noMatchPassword: { id: 'message.no-match-password', defaultMessage: 'Passwords do not match.' },
|
||||
goToSettings: {
|
||||
id: 'message.go-to-settings',
|
||||
|
|
@ -183,17 +196,6 @@ export const messages = defineMessages({
|
|||
id: 'message.event-log',
|
||||
defaultMessage: '{event} on {url}',
|
||||
},
|
||||
newVersionAvailable: {
|
||||
id: 'new-version-available',
|
||||
defaultMessage: 'A new version of Umami {version} is available!',
|
||||
},
|
||||
});
|
||||
|
||||
export const devices = defineMessages({
|
||||
desktop: { id: 'metrics.device.desktop', defaultMessage: 'Desktop' },
|
||||
laptop: { id: 'metrics.device.laptop', defaultMessage: 'Laptop' },
|
||||
tablet: { id: 'metrics.device.tablet', defaultMessage: 'Tablet' },
|
||||
mobile: { id: 'metrics.device.mobile', defaultMessage: 'Mobile' },
|
||||
});
|
||||
|
||||
export function getMessage(id, formatMessage) {
|
||||
|
|
@ -201,7 +203,3 @@ export function getMessage(id, formatMessage) {
|
|||
|
||||
return message ? formatMessage(message) : id;
|
||||
}
|
||||
|
||||
export function getDeviceMessage(device) {
|
||||
return devices[device] || labels.unknown;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,26 @@
|
|||
import { Loading } from 'react-basics';
|
||||
import { useIntl } from 'react-intl';
|
||||
import EmptyPlaceholder from 'components/common/EmptyPlaceholder';
|
||||
import { labels, messages } from 'components/messages';
|
||||
import TeamWebsitesTable from 'components/pages/settings/teams/TeamWebsitesTable';
|
||||
import useApi from 'hooks/useApi';
|
||||
import WebsitesTable from 'components/pages/settings/websites/WebsitesTable';
|
||||
import { messages } from 'components/messages';
|
||||
import {
|
||||
ActionForm,
|
||||
Button,
|
||||
Icon,
|
||||
Icons,
|
||||
Loading,
|
||||
Modal,
|
||||
ModalTrigger,
|
||||
Text,
|
||||
useToast,
|
||||
} from 'react-basics';
|
||||
import { useIntl } from 'react-intl';
|
||||
import WebsiteAddTeamForm from 'components/pages/settings/teams/WebsiteAddTeamForm';
|
||||
|
||||
export default function TeamWebsites({ teamId }) {
|
||||
const { toast, showToast } = useToast();
|
||||
const { formatMessage } = useIntl();
|
||||
const { get, useQuery } = useApi();
|
||||
const { data, isLoading } = useQuery(['teams:websites', teamId], () =>
|
||||
const { data, isLoading, refetch } = useQuery(['teams:websites', teamId], () =>
|
||||
get(`/teams/${teamId}/websites`),
|
||||
);
|
||||
const hasData = data && data.length !== 0;
|
||||
|
|
@ -16,10 +29,37 @@ export default function TeamWebsites({ teamId }) {
|
|||
return <Loading icon="dots" position="block" />;
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
await refetch();
|
||||
showToast({ message: formatMessage(messages.saved), variant: 'success' });
|
||||
};
|
||||
|
||||
const addButton = (
|
||||
<ModalTrigger>
|
||||
<Button variant="primary">
|
||||
<Icon>
|
||||
<Icons.Plus />
|
||||
</Icon>
|
||||
<Text>{formatMessage(labels.addWebsite)}</Text>
|
||||
</Button>
|
||||
<Modal title={formatMessage(labels.addWebsite)}>
|
||||
{close => <WebsiteAddTeamForm teamId={teamId} onSave={handleSave} onClose={close} />}
|
||||
</Modal>
|
||||
</ModalTrigger>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{hasData && <WebsitesTable data={data} />}
|
||||
{!hasData && formatMessage(messages.noData)}
|
||||
{toast}
|
||||
{hasData && (
|
||||
<ActionForm description={formatMessage(messages.websitesAreShared)}>{addButton}</ActionForm>
|
||||
)}
|
||||
{hasData && <TeamWebsitesTable teamId={teamId} data={data} onSave={handleSave} />}
|
||||
{!hasData && (
|
||||
<EmptyPlaceholder message={formatMessage(messages.noTeamWebsites)}>
|
||||
{addButton}
|
||||
</EmptyPlaceholder>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
103
components/pages/settings/teams/TeamWebsitesTable.js
Normal file
103
components/pages/settings/teams/TeamWebsitesTable.js
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import Link from 'next/link';
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableColumn,
|
||||
Button,
|
||||
Text,
|
||||
Icon,
|
||||
Icons,
|
||||
Flexbox,
|
||||
} from 'react-basics';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { labels } from 'components/messages';
|
||||
import useUser from 'hooks/useUser';
|
||||
import useApi from 'hooks/useApi';
|
||||
|
||||
export default function TeamWebsitesTable({ teamId, data = [], onSave }) {
|
||||
const { formatMessage } = useIntl();
|
||||
const { user } = useUser();
|
||||
const { del, useMutation } = useApi();
|
||||
const { mutate } = useMutation(data => del(`/teamWebsites/${data.teamWebsiteId}`));
|
||||
|
||||
const columns = [
|
||||
{ name: 'name', label: formatMessage(labels.name), style: { flex: 2 } },
|
||||
{ name: 'domain', label: formatMessage(labels.domain) },
|
||||
{ name: 'action', label: ' ' },
|
||||
];
|
||||
|
||||
const handleRemoveWebsite = teamWebsiteId => {
|
||||
mutate(
|
||||
{ teamWebsiteId },
|
||||
{
|
||||
onSuccess: async () => {
|
||||
onSave();
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Table columns={columns} rows={data}>
|
||||
<TableHeader>
|
||||
{(column, index) => {
|
||||
return (
|
||||
<TableColumn key={index} style={{ ...column.style }}>
|
||||
{column.label}
|
||||
</TableColumn>
|
||||
);
|
||||
}}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(row, keys, rowIndex) => {
|
||||
const { id: teamWebsiteId } = row;
|
||||
const { id: websiteId, name, domain, userId } = row.website;
|
||||
const { teamUser } = row.team;
|
||||
const owner = teamUser[0];
|
||||
const canRemove = user.id === userId || user.id === owner.userId;
|
||||
|
||||
row.name = name;
|
||||
row.domain = domain;
|
||||
|
||||
row.action = (
|
||||
<Flexbox flex={1} justifyContent="end" gap={10}>
|
||||
<Link href={`/websites/${websiteId}`} target="_blank">
|
||||
<Button>
|
||||
<Icon>
|
||||
<Icons.External />
|
||||
</Icon>
|
||||
<Text>{formatMessage(labels.view)}</Text>
|
||||
</Button>
|
||||
</Link>
|
||||
{canRemove && (
|
||||
<Button onClick={() => handleRemoveWebsite(teamWebsiteId)}>
|
||||
<Icon>
|
||||
<Icons.Trash />
|
||||
</Icon>
|
||||
<Text>{formatMessage(labels.remove)}</Text>
|
||||
</Button>
|
||||
)}
|
||||
</Flexbox>
|
||||
);
|
||||
|
||||
return (
|
||||
<TableRow key={rowIndex} data={row} keys={keys}>
|
||||
{(data, key, colIndex) => {
|
||||
return (
|
||||
<TableCell key={colIndex} style={{ ...columns[colIndex]?.style }}>
|
||||
<Flexbox flex={1} alignItems="center">
|
||||
{data[key]}
|
||||
</Flexbox>
|
||||
</TableCell>
|
||||
);
|
||||
}}
|
||||
</TableRow>
|
||||
);
|
||||
}}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
|
@ -76,7 +76,10 @@ export default function TeamsList() {
|
|||
{hasData && <TeamsTable data={data} onDelete={handleDelete} />}
|
||||
{!hasData && (
|
||||
<EmptyPlaceholder message={formatMessage(messages.noTeams)}>
|
||||
{createButton}
|
||||
<Flexbox gap={10}>
|
||||
{joinButton}
|
||||
{createButton}
|
||||
</Flexbox>
|
||||
</EmptyPlaceholder>
|
||||
)}
|
||||
</Page>
|
||||
|
|
|
|||
|
|
@ -1,26 +1,28 @@
|
|||
import { labels } from 'components/messages';
|
||||
import useUser from 'hooks/useUser';
|
||||
import { ROLES } from 'lib/constants';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
Button,
|
||||
Flexbox,
|
||||
Icon,
|
||||
Icons,
|
||||
Modal,
|
||||
ModalTrigger,
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableColumn,
|
||||
Button,
|
||||
Icon,
|
||||
Flexbox,
|
||||
Icons,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Text,
|
||||
ModalTrigger,
|
||||
Modal,
|
||||
} from 'react-basics';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { labels } from 'components/messages';
|
||||
import { ROLES } from 'lib/constants';
|
||||
import TeamDeleteForm from './TeamDeleteForm';
|
||||
|
||||
export default function TeamsTable({ data = [], onDelete }) {
|
||||
const { formatMessage } = useIntl();
|
||||
const { user } = useUser();
|
||||
|
||||
const columns = [
|
||||
{ name: 'name', label: formatMessage(labels.name), style: { flex: 2 } },
|
||||
|
|
@ -42,10 +44,12 @@ export default function TeamsTable({ data = [], onDelete }) {
|
|||
<TableBody>
|
||||
{(row, keys, rowIndex) => {
|
||||
const { id } = row;
|
||||
const owner = row.teamUser.find(({ role }) => role === ROLES.teamOwner);
|
||||
const showDelete = user.id === owner?.userId;
|
||||
|
||||
const rowData = {
|
||||
...row,
|
||||
owner: row.teamUser.find(({ role }) => role === ROLES.teamOwner)?.user?.username,
|
||||
owner: owner?.user?.username,
|
||||
action: (
|
||||
<Flexbox flex={1} gap={10} justifyContent="end">
|
||||
<Link href={`/settings/teams/${id}`}>
|
||||
|
|
@ -56,24 +60,26 @@ export default function TeamsTable({ data = [], onDelete }) {
|
|||
<Text>{formatMessage(labels.edit)}</Text>
|
||||
</Button>
|
||||
</Link>
|
||||
<ModalTrigger>
|
||||
<Button>
|
||||
<Icon>
|
||||
<Icons.Trash />
|
||||
</Icon>
|
||||
<Text>{formatMessage(labels.delete)}</Text>
|
||||
</Button>
|
||||
<Modal title={formatMessage(labels.deleteTeam)}>
|
||||
{close => (
|
||||
<TeamDeleteForm
|
||||
teamId={row.id}
|
||||
teamName={row.name}
|
||||
onSave={onDelete}
|
||||
onClose={close}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</ModalTrigger>
|
||||
{showDelete && (
|
||||
<ModalTrigger>
|
||||
<Button>
|
||||
<Icon>
|
||||
<Icons.Trash />
|
||||
</Icon>
|
||||
<Text>{formatMessage(labels.delete)}</Text>
|
||||
</Button>
|
||||
<Modal title={formatMessage(labels.deleteTeam)}>
|
||||
{close => (
|
||||
<TeamDeleteForm
|
||||
teamId={row.id}
|
||||
teamName={row.name}
|
||||
onSave={onDelete}
|
||||
onClose={close}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</ModalTrigger>
|
||||
)}
|
||||
</Flexbox>
|
||||
),
|
||||
};
|
||||
|
|
|
|||
62
components/pages/settings/teams/WebsiteAddTeamForm.js
Normal file
62
components/pages/settings/teams/WebsiteAddTeamForm.js
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { labels } from 'components/messages';
|
||||
import useApi from 'hooks/useApi';
|
||||
import { useRef, useState } from 'react';
|
||||
import { Button, Dropdown, Form, FormButtons, FormRow, Item, SubmitButton } from 'react-basics';
|
||||
import { useIntl } from 'react-intl';
|
||||
import WebsiteTags from './WebsiteTags';
|
||||
|
||||
export default function WebsiteAddTeamForm({ teamId, onSave, onClose }) {
|
||||
const { formatMessage } = useIntl();
|
||||
const { get, post, useQuery, useMutation } = useApi();
|
||||
const { mutate, error } = useMutation(data => post(`/teams/${teamId}/websites`, data));
|
||||
const { data: websites } = useQuery(['websites'], () => get('/websites'));
|
||||
const [newWebsites, setNewWebsites] = useState([]);
|
||||
const formRef = useRef();
|
||||
|
||||
const handleSubmit = () => {
|
||||
mutate(
|
||||
{ websiteIds: newWebsites },
|
||||
{
|
||||
onSuccess: async () => {
|
||||
onSave();
|
||||
onClose();
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleAddWebsite = value => {
|
||||
if (!newWebsites.some(a => a === value)) {
|
||||
const nextValue = [...newWebsites];
|
||||
|
||||
nextValue.push(value);
|
||||
|
||||
setNewWebsites(nextValue);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveWebsite = value => {
|
||||
const newValue = newWebsites.filter(a => a !== value);
|
||||
|
||||
setNewWebsites(newValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form onSubmit={handleSubmit} error={error} ref={formRef}>
|
||||
<FormRow label={formatMessage(labels.websites)}>
|
||||
<Dropdown items={websites} onChange={handleAddWebsite}>
|
||||
{({ id, name }) => <Item key={id}>{name}</Item>}
|
||||
</Dropdown>
|
||||
</FormRow>
|
||||
<WebsiteTags items={websites} websites={newWebsites} onClick={handleRemoveWebsite} />
|
||||
<FormButtons flex>
|
||||
<SubmitButton disabled={newWebsites && newWebsites.length === 0}>
|
||||
{formatMessage(labels.addWebsites)}
|
||||
</SubmitButton>
|
||||
<Button onClick={onClose}>{formatMessage(labels.cancel)}</Button>
|
||||
</FormButtons>
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
29
components/pages/settings/teams/WebsiteTags.js
Normal file
29
components/pages/settings/teams/WebsiteTags.js
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { Button, Icon, Icons, Text } from 'react-basics';
|
||||
import styles from './WebsiteTags.module.css';
|
||||
|
||||
export default function WebsiteTags({ items = [], websites = [], onClick }) {
|
||||
if (websites.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.filters}>
|
||||
{websites.map(websiteId => {
|
||||
const website = items.find(a => a.id === websiteId);
|
||||
|
||||
return (
|
||||
<div key={websiteId} className={styles.tag}>
|
||||
<Button onClick={() => onClick(websiteId)} variant="primary" size="sm">
|
||||
<Text>
|
||||
<b>{`${website.name}`}</b>
|
||||
</Text>
|
||||
<Icon>
|
||||
<Icons.Close />
|
||||
</Icon>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
components/pages/settings/teams/WebsiteTags.module.css
Normal file
11
components/pages/settings/teams/WebsiteTags.module.css
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
.filters {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.tag {
|
||||
text-align: center;
|
||||
margin-bottom: 10px;
|
||||
margin-right: 20px;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue