Update collection permissions UI (#6917)

This commit is contained in:
Tom Moor
2024-05-16 19:45:09 -04:00
committed by GitHub
parent 728c68be58
commit cae013837b
34 changed files with 1088 additions and 287 deletions

View File

@@ -20,6 +20,7 @@ import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
import { createAction } from "~/actions";
import { CollectionSection } from "~/actions/sections";
import { setPersistedState } from "~/hooks/usePersistedState";
import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
import history from "~/utils/history";
import { searchPath } from "~/utils/routeHelpers";
@@ -99,7 +100,8 @@ export const editCollectionPermissions = createAction({
icon: <PadlockIcon />,
visible: ({ stores, activeCollectionId }) =>
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update,
stores.policies.abilities(activeCollectionId).update &&
!FeatureFlags.isEnabled(Feature.newCollectionSharing),
perform: ({ t, activeCollectionId }) => {
if (!activeCollectionId) {
return;

View File

@@ -1,11 +1,18 @@
import copy from "copy-to-clipboard";
import { CopyIcon, ToolsIcon, TrashIcon, UserIcon } from "outline-icons";
import {
BeakerIcon,
CopyIcon,
ToolsIcon,
TrashIcon,
UserIcon,
} from "outline-icons";
import * as React from "react";
import { toast } from "sonner";
import { createAction } from "~/actions";
import { DeveloperSection } from "~/actions/sections";
import env from "~/env";
import { client } from "~/utils/ApiClient";
import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
import Logger from "~/utils/Logger";
import { deleteAllDatabases } from "~/utils/developer";
import history from "~/utils/history";
@@ -104,7 +111,7 @@ export const createToast = createAction({
name: "Create toast",
section: DeveloperSection,
visible: () => env.ENVIRONMENT === "development",
perform: async () => {
perform: () => {
toast.message("Hello world", {
duration: 30000,
});
@@ -115,7 +122,7 @@ export const toggleDebugLogging = createAction({
name: ({ t }) => t("Toggle debug logging"),
icon: <ToolsIcon />,
section: DeveloperSection,
perform: async ({ t }) => {
perform: ({ t }) => {
Logger.debugLoggingEnabled = !Logger.debugLoggingEnabled;
toast.message(
Logger.debugLoggingEnabled
@@ -125,6 +132,30 @@ export const toggleDebugLogging = createAction({
},
});
export const toggleFeatureFlag = createAction({
name: "Toggle feature flag",
icon: <BeakerIcon />,
section: DeveloperSection,
visible: () => env.ENVIRONMENT === "development",
children: Object.values(Feature).map((flag) =>
createAction({
id: `flag-${flag}`,
name: flag,
selected: () => FeatureFlags.isEnabled(flag),
section: DeveloperSection,
perform: () => {
if (FeatureFlags.isEnabled(flag)) {
FeatureFlags.disable(flag);
toast.success(`Disabled feature flag: ${flag}`);
} else {
FeatureFlags.enable(flag);
toast.success(`Enabled feature flag: ${flag}`);
}
},
})
),
});
export const developer = createAction({
name: ({ t }) => t("Development"),
keywords: "debug",
@@ -133,10 +164,11 @@ export const developer = createAction({
section: DeveloperSection,
children: [
copyId,
clearIndexedDB,
toggleDebugLogging,
toggleFeatureFlag,
createToast,
createTestUsers,
clearIndexedDB,
],
});

View File

@@ -40,7 +40,7 @@ import DocumentPublish from "~/scenes/DocumentPublish";
import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInTrash";
import DocumentTemplatizeDialog from "~/components/DocumentTemplatizeDialog";
import DuplicateDialog from "~/components/DuplicateDialog";
import SharePopover from "~/components/Sharing";
import SharePopover from "~/components/Sharing/Document";
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
import { createAction } from "~/actions";
import { DocumentSection, TrashSection } from "~/actions/sections";

View File

@@ -18,6 +18,7 @@ import Switch from "~/components/Switch";
import Text from "~/components/Text";
import useBoolean from "~/hooks/useBoolean";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
export interface FormData {
name: string;
@@ -138,16 +139,18 @@ export const CollectionForm = observer(function CollectionForm_({
/>
)}
{team.sharing && !collection && (
<Switch
id="sharing"
label={t("Public document sharing")}
note={t(
"Allow documents within this collection to be shared publicly on the internet."
)}
{...register("sharing")}
/>
)}
{team.sharing &&
(!collection ||
FeatureFlags.isEnabled(Feature.newCollectionSharing)) && (
<Switch
id="sharing"
label={t("Public document sharing")}
note={t(
"Allow documents within this collection to be shared publicly on the internet."
)}
{...register("sharing")}
/>
)}
<Flex justify="flex-end">
<Button

View File

@@ -3,21 +3,30 @@ import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { s } from "@shared/styles";
import InputSelect, { Props as SelectProps } from "~/components/InputSelect";
import { Permission } from "~/types";
import { EmptySelectValue, Permission } from "~/types";
export default function InputMemberPermissionSelect(
props: Partial<SelectProps> & { permissions: Permission[] }
) {
const { value, onChange, ...rest } = props;
const { t } = useTranslation();
const handleChange = React.useCallback(
(value) => {
onChange?.(value === EmptySelectValue ? null : value);
},
[onChange]
);
return (
<Select
label={t("Permissions")}
options={props.permissions}
ariaLabel={t("Permissions")}
onChange={handleChange}
value={value || EmptySelectValue}
labelHidden
nude
{...props}
{...rest}
/>
);
}

View File

@@ -23,12 +23,14 @@ import {
Placement,
} from "./ContextMenu";
import { MenuAnchorCSS } from "./ContextMenu/MenuItem";
import Separator from "./ContextMenu/Separator";
import { LabelText } from "./Input";
export type Option = {
label: string | JSX.Element;
value: string;
description?: string;
divider?: boolean;
};
export type Props = {
@@ -47,6 +49,7 @@ export type Props = {
/** @deprecated Removing soon, do not use. */
note?: React.ReactNode;
onChange?: (value: string | null) => void;
style?: React.CSSProperties;
};
export interface InputSelectRef {
@@ -247,16 +250,19 @@ const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
const isSelected = select.selectedValue === opt.value;
const Icon = isSelected ? CheckmarkIcon : Spacer;
return (
<StyledSelectOption
{...select}
value={opt.value}
key={opt.value}
ref={isSelected ? selectedRef : undefined}
>
<Icon />
&nbsp;
{labelForOption(opt)}
</StyledSelectOption>
<>
{opt.divider && <Separator />}
<StyledSelectOption
{...select}
value={opt.value}
key={opt.value}
ref={isSelected ? selectedRef : undefined}
>
<Icon />
&nbsp;
{labelForOption(opt)}
</StyledSelectOption>
</>
);
})
: null}

View File

@@ -0,0 +1,176 @@
import { observer } from "mobx-react";
import { GroupIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useTheme } from "styled-components";
import Squircle from "@shared/components/Squircle";
import { CollectionPermission } from "@shared/types";
import Collection from "~/models/Collection";
import Avatar, { AvatarSize } from "~/components/Avatar/Avatar";
import InputMemberPermissionSelect from "~/components/InputMemberPermissionSelect";
import usePolicy from "~/hooks/usePolicy";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import { EmptySelectValue, Permission } from "~/types";
import { ListItem } from "../components/ListItem";
type Props = {
/** Collection to which team members are supposed to be invited */
collection: Collection;
/** Children to be rendered before the list of members */
children?: React.ReactNode;
/** List of users and groups that have been invited during the current editing session */
invitedInSession: string[];
};
function CollectionMemberList({ collection, invitedInSession }: Props) {
const { memberships, collectionGroupMemberships } = useStores();
const can = usePolicy(collection);
const { t } = useTranslation();
const theme = useTheme();
const collectionId = collection.id;
const { request: fetchMemberships } = useRequest(
React.useCallback(
() => memberships.fetchAll({ id: collectionId }),
[memberships, collectionId]
)
);
const { request: fetchGroupMemberships } = useRequest(
React.useCallback(
() => collectionGroupMemberships.fetchAll({ id: collectionId }),
[collectionGroupMemberships, collectionId]
)
);
React.useEffect(() => {
void fetchMemberships();
void fetchGroupMemberships();
}, [fetchMemberships, fetchGroupMemberships]);
const permissions = React.useMemo(
() =>
[
{
label: t("Admin"),
value: CollectionPermission.Admin,
},
{
label: t("Can edit"),
value: CollectionPermission.ReadWrite,
},
{
label: t("View only"),
value: CollectionPermission.Read,
},
{
divider: true,
label: t("Remove"),
value: EmptySelectValue,
},
] as Permission[],
[t]
);
return (
<>
{collectionGroupMemberships
.inCollection(collection.id)
.sort((a, b) =>
(
(invitedInSession.includes(a.group.id) ? "_" : "") + a.group.name
).localeCompare(b.group.name)
)
.map((membership) => (
<ListItem
key={membership.id}
image={
<Squircle color={theme.text} size={AvatarSize.Medium}>
<GroupIcon color={theme.background} size={16} />
</Squircle>
}
title={membership.group.name}
subtitle={t("{{ count }} member", {
count: membership.group.memberCount,
})}
actions={
<div style={{ marginRight: -8 }}>
<InputMemberPermissionSelect
style={{ margin: 0 }}
permissions={permissions}
onChange={async (permission: CollectionPermission) => {
if (permission) {
await collectionGroupMemberships.create({
collectionId: collection.id,
groupId: membership.groupId,
permission,
});
} else {
await collectionGroupMemberships.delete({
collectionId: collection.id,
groupId: membership.groupId,
});
}
}}
disabled={!can.update}
value={membership.permission}
labelHidden
nude
/>
</div>
}
/>
))}
{memberships
.inCollection(collection.id)
.sort((a, b) =>
(
(invitedInSession.includes(a.user.id) ? "_" : "") + a.user.name
).localeCompare(b.user.name)
)
.map((membership) => (
<ListItem
key={membership.id}
image={
<Avatar
model={membership.user}
size={AvatarSize.Medium}
showBorder={false}
/>
}
title={membership.user.name}
subtitle={membership.user.email}
actions={
<div style={{ marginRight: -8 }}>
<InputMemberPermissionSelect
style={{ margin: 0 }}
permissions={permissions}
onChange={async (permission: CollectionPermission) => {
if (permission) {
await memberships.create({
collectionId: collection.id,
userId: membership.userId,
permission,
});
} else {
await memberships.delete({
collectionId: collection.id,
userId: membership.userId,
});
}
}}
disabled={!can.update}
value={membership.permission}
labelHidden
nude
/>
</div>
}
/>
))}
</>
);
}
export default observer(CollectionMemberList);

View File

@@ -0,0 +1,340 @@
import { isEmail } from "class-validator";
import { m } from "framer-motion";
import { observer } from "mobx-react";
import { BackIcon, LinkIcon, UserIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useTheme } from "styled-components";
import Squircle from "@shared/components/Squircle";
import { CollectionPermission, UserRole } from "@shared/types";
import Collection from "~/models/Collection";
import Group from "~/models/Group";
import Share from "~/models/Share";
import User from "~/models/User";
import Avatar, { AvatarSize } from "~/components/Avatar/Avatar";
import ButtonSmall from "~/components/ButtonSmall";
import CopyToClipboard from "~/components/CopyToClipboard";
import InputSelectPermission from "~/components/InputSelectPermission";
import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip";
import { createAction } from "~/actions";
import { UserSection } from "~/actions/sections";
import useActionContext from "~/hooks/useActionContext";
import useBoolean from "~/hooks/useBoolean";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useKeyDown from "~/hooks/useKeyDown";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { collectionPath, urlify } from "~/utils/routeHelpers";
import { Wrapper, presence } from "../components";
import { ListItem } from "../components/ListItem";
import { SearchInput } from "../components/SearchInput";
import { Suggestions } from "../components/Suggestions";
import CollectionMemberList from "./CollectionMemberList";
type Props = {
collection: Collection;
/** The existing share model, if any. */
share: Share | null | undefined;
/** Callback fired when the popover requests to be closed. */
onRequestClose: () => void;
/** Whether the popover is visible. */
visible: boolean;
};
function SharePopover({ collection, visible, onRequestClose }: Props) {
const theme = useTheme();
const team = useCurrentTeam();
const { collectionGroupMemberships, users, groups, memberships } =
useStores();
const { t } = useTranslation();
const can = usePolicy(collection);
const [query, setQuery] = React.useState("");
const [picker, showPicker, hidePicker] = useBoolean();
const [hasRendered, setHasRendered] = React.useState(visible);
const [pendingIds, setPendingIds] = React.useState<string[]>([]);
const [invitedInSession, setInvitedInSession] = React.useState<string[]>([]);
const timeout = React.useRef<ReturnType<typeof setTimeout>>();
const context = useActionContext();
useKeyDown(
"Escape",
(ev) => {
ev.preventDefault();
ev.stopImmediatePropagation();
if (picker) {
hidePicker();
} else {
onRequestClose();
}
},
{
allowInInput: true,
}
);
// Clear the query when picker is closed
React.useEffect(() => {
if (!picker) {
setQuery("");
}
}, [picker]);
React.useEffect(() => {
if (visible) {
setHasRendered(true);
}
}, [visible]);
const handleCopied = React.useCallback(() => {
onRequestClose();
timeout.current = setTimeout(() => {
toast.message(t("Link copied to clipboard"));
}, 100);
return () => {
if (timeout.current) {
clearTimeout(timeout.current);
}
};
}, [onRequestClose, t]);
const handleQuery = React.useCallback(
(event) => {
showPicker();
setQuery(event.target.value);
},
[showPicker, setQuery]
);
const handleAddPendingId = React.useCallback(
(id: string) => {
setPendingIds((prev) => [...prev, id]);
},
[setPendingIds]
);
const handleRemovePendingId = React.useCallback(
(id: string) => {
setPendingIds((prev) => prev.filter((i) => i !== id));
},
[setPendingIds]
);
const inviteAction = React.useMemo(
() =>
createAction({
name: t("Invite"),
section: UserSection,
perform: async () => {
const invited = await Promise.all(
pendingIds.map(async (idOrEmail) => {
let user, group;
// convert email to user
if (isEmail(idOrEmail)) {
const response = await users.invite([
{
email: idOrEmail,
name: idOrEmail,
role: team.defaultUserRole,
},
]);
user = response.users[0];
} else {
user = users.get(idOrEmail);
group = groups.get(idOrEmail);
}
if (user) {
await memberships.create({
collectionId: collection.id,
userId: user.id,
permission:
user?.role === UserRole.Viewer ||
user?.role === UserRole.Guest
? CollectionPermission.Read
: CollectionPermission.ReadWrite,
});
return user;
}
if (group) {
await collectionGroupMemberships.create({
collectionId: collection.id,
groupId: group.id,
permission:
user?.role === UserRole.Viewer ||
user?.role === UserRole.Guest
? CollectionPermission.Read
: CollectionPermission.ReadWrite,
});
return group;
}
})
);
const invitedUsers = invited.filter((item) => item instanceof User);
const invitedGroups = invited.filter((item) => item instanceof Group);
// Special case for the common action of adding a single user.
if (invitedUsers.length === 1 && invited.length === 1) {
const user = invitedUsers[0];
toast.message(
t("{{ userName }} was added to the collection", {
userName: user.name,
}),
{
icon: <Avatar model={user} size={AvatarSize.Toast} />,
}
);
} else if (invitedGroups.length === 1 && invited.length === 1) {
const group = invitedGroups[0];
toast.success(
t("{{ userName }} was added to the collection", {
userName: group.name,
})
);
} else if (invitedGroups.length === 0) {
toast.success(
t("{{ count }} people added to the collection", {
count: invitedUsers.length,
})
);
} else {
toast.success(
t(
"{{ count }} people and {{ count2 }} groups added to the collection",
{
count: invitedUsers.length,
count2: invitedGroups.length,
}
)
);
}
setInvitedInSession((prev) => [...prev, ...pendingIds]);
setPendingIds([]);
hidePicker();
},
}),
[
collection.id,
hidePicker,
memberships,
pendingIds,
t,
team.defaultUserRole,
users,
]
);
if (!hasRendered) {
return null;
}
const backButton = (
<>
{picker && (
<NudeButton
key="back"
as={m.button}
{...presence}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
hidePicker();
}}
>
<BackIcon />
</NudeButton>
)}
</>
);
const rightButton = picker ? (
pendingIds.length ? (
<ButtonSmall action={inviteAction} context={context} key="invite">
{t("Add")}
</ButtonSmall>
) : null
) : (
<Tooltip
content={t("Copy link")}
delay={500}
placement="top"
key="copy-link"
>
<CopyToClipboard
text={urlify(collectionPath(collection.path))}
onCopy={handleCopied}
>
<NudeButton type="button">
<LinkIcon size={20} />
</NudeButton>
</CopyToClipboard>
</Tooltip>
);
return (
<Wrapper>
{can.update && (
<SearchInput
onChange={handleQuery}
onClick={showPicker}
query={query}
back={backButton}
action={rightButton}
/>
)}
{picker && (
<div>
<Suggestions
query={query}
collection={collection}
pendingIds={pendingIds}
addPendingId={handleAddPendingId}
removePendingId={handleRemovePendingId}
/>
</div>
)}
<div style={{ display: picker ? "none" : "block" }}>
<ListItem
image={
<Squircle color={theme.accent} size={AvatarSize.Medium}>
<UserIcon color={theme.accentText} size={16} />
</Squircle>
}
title={t("All members")}
subtitle={t("Everyone in the workspace")}
actions={
<div style={{ marginRight: -8 }}>
<InputSelectPermission
style={{ margin: 0 }}
onChange={(permission) => {
void collection.save({ permission });
}}
disabled={!can.update}
value={collection?.permission}
labelHidden
nude
/>
</div>
}
/>
<CollectionMemberList
collection={collection}
invitedInSession={invitedInSession}
/>
</div>
</Wrapper>
);
}
export default observer(SharePopover);

View File

@@ -1,7 +1,7 @@
import { t } from "i18next";
import orderBy from "lodash/orderBy";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import { toast } from "sonner";
import { Pagination } from "@shared/constants";
@@ -13,29 +13,23 @@ import usePolicy from "~/hooks/usePolicy";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import { homePath } from "~/utils/routeHelpers";
import MemberListItem from "./MemberListItem";
import MemberListItem from "./DocumentMemberListItem";
type Props = {
/** Document to which team members are supposed to be invited */
document: Document;
/** Children to be rendered before the list of members */
children?: React.ReactNode;
/** List of users that have been invited to the document during the current editing session */
/** List of users that have been invited during the current editing session */
invitedInSession: string[];
};
function DocumentMembersList({ document, invitedInSession }: Props) {
const { users, userMemberships } = useStores();
const { userMemberships } = useStores();
const user = useCurrentUser();
const history = useHistory();
const can = usePolicy(document);
const { loading: loadingTeamMembers, request: fetchTeamMembers } = useRequest(
React.useCallback(
() => users.fetchPage({ limit: Pagination.defaultLimit }),
[users]
)
);
const { t } = useTranslation();
const { loading: loadingDocumentMembers, request: fetchDocumentMembers } =
useRequest(
@@ -50,9 +44,8 @@ function DocumentMembersList({ document, invitedInSession }: Props) {
);
React.useEffect(() => {
void fetchTeamMembers();
void fetchDocumentMembers();
}, [fetchTeamMembers, fetchDocumentMembers]);
}, [fetchDocumentMembers]);
const handleRemoveUser = React.useCallback(
async (item) => {
@@ -112,7 +105,7 @@ function DocumentMembersList({ document, invitedInSession }: Props) {
[document.members, invitedInSession]
);
if (loadingTeamMembers || loadingDocumentMembers) {
if (loadingDocumentMembers) {
return <LoadingIndicator />;
}

View File

@@ -1,5 +1,4 @@
import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
@@ -11,9 +10,8 @@ import UserMembership from "~/models/UserMembership";
import Avatar from "~/components/Avatar";
import { AvatarSize } from "~/components/Avatar/Avatar";
import InputMemberPermissionSelect from "~/components/InputMemberPermissionSelect";
import ListItem from "~/components/List/Item";
import { hover } from "~/styles";
import { EmptySelectValue, Permission } from "~/types";
import { ListItem } from "../components/ListItem";
type Props = {
user: User;
@@ -24,7 +22,7 @@ type Props = {
onUpdate?: (permission: DocumentPermission) => void;
};
const MemberListItem = ({
const DocumentMemberListItem = ({
user,
membership,
onRemove,
@@ -54,7 +52,7 @@ const MemberListItem = ({
value: DocumentPermission.ReadWrite,
},
{
label: t("No access"),
label: t("Remove"),
value: EmptySelectValue,
},
];
@@ -69,7 +67,7 @@ const MemberListItem = ({
const MaybeLink = membership?.source ? StyledLink : React.Fragment;
return (
<StyledListItem
<ListItem
title={user.name}
image={
<Avatar model={user} size={AvatarSize.Medium} showBorder={false} />
@@ -122,26 +120,9 @@ const MemberListItem = ({
);
};
export const InviteIcon = styled(PlusIcon)`
opacity: 0;
`;
export const StyledListItem = styled(ListItem).attrs({
small: true,
border: false,
})`
margin: 0 -16px;
padding: 6px 16px;
border-radius: 8px;
&: ${hover} ${InviteIcon} {
opacity: 1;
}
`;
const StyledLink = styled(Link)`
color: ${s("textTertiary")};
text-decoration: underline;
`;
export default observer(MemberListItem);
export default observer(DocumentMemberListItem);

View File

@@ -12,11 +12,11 @@ import Text from "~/components/Text";
import useCurrentUser from "~/hooks/useCurrentUser";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import Avatar from "../Avatar";
import { AvatarSize } from "../Avatar/Avatar";
import CollectionIcon from "../Icons/CollectionIcon";
import Tooltip from "../Tooltip";
import { StyledListItem } from "./MemberListItem";
import Avatar from "../../Avatar";
import { AvatarSize } from "../../Avatar/Avatar";
import CollectionIcon from "../../Icons/CollectionIcon";
import Tooltip from "../../Tooltip";
import { ListItem } from "../components/ListItem";
type Props = {
/** The document being shared. */
@@ -36,7 +36,7 @@ export const OtherAccess = observer(({ document, children }: Props) => {
{collection ? (
<>
{collection.permission ? (
<StyledListItem
<ListItem
image={
<Squircle color={theme.accent} size={AvatarSize.Medium}>
<UserIcon color={theme.accentText} size={16} />
@@ -53,7 +53,7 @@ export const OtherAccess = observer(({ document, children }: Props) => {
}
/>
) : usersInCollection ? (
<StyledListItem
<ListItem
image={
<Squircle color={collection.color} size={AvatarSize.Medium}>
<CollectionIcon
@@ -68,7 +68,7 @@ export const OtherAccess = observer(({ document, children }: Props) => {
actions={<AccessTooltip>{t("Can view")}</AccessTooltip>}
/>
) : (
<StyledListItem
<ListItem
image={<Avatar model={user} showBorder={false} />}
title={user.name}
subtitle={t("You have full access")}
@@ -79,7 +79,7 @@ export const OtherAccess = observer(({ document, children }: Props) => {
</>
) : document.isDraft ? (
<>
<StyledListItem
<ListItem
image={<Avatar model={document.createdBy} showBorder={false} />}
title={document.createdBy?.name}
actions={
@@ -93,7 +93,7 @@ export const OtherAccess = observer(({ document, children }: Props) => {
) : (
<>
{children}
<StyledListItem
<ListItem
image={
<Squircle color={theme.accent} size={AvatarSize.Medium}>
<MoreIcon color={theme.accentText} size={16} />

View File

@@ -18,13 +18,13 @@ import Switch from "~/components/Switch";
import env from "~/env";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { AvatarSize } from "../Avatar/Avatar";
import CopyToClipboard from "../CopyToClipboard";
import NudeButton from "../NudeButton";
import { ResizingHeightContainer } from "../ResizingHeightContainer";
import Text from "../Text";
import Tooltip from "../Tooltip";
import { StyledListItem } from "./MemberListItem";
import { AvatarSize } from "../../Avatar/Avatar";
import CopyToClipboard from "../../CopyToClipboard";
import NudeButton from "../../NudeButton";
import { ResizingHeightContainer } from "../../ResizingHeightContainer";
import Text from "../../Text";
import Tooltip from "../../Tooltip";
import { ListItem } from "../components/ListItem";
type Props = {
/** The document to share. */
@@ -122,7 +122,7 @@ function PublicAccess({ document, share, sharedParent }: Props) {
return (
<Wrapper>
<StyledListItem
<ListItem
title={t("Web")}
subtitle={
<>

View File

@@ -1,37 +1,34 @@
import { isEmail } from "class-validator";
import { AnimatePresence, m } from "framer-motion";
import { m } from "framer-motion";
import { observer } from "mobx-react";
import { BackIcon, LinkIcon } from "outline-icons";
import { darken } from "polished";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components";
import { s } from "@shared/styles";
import { DocumentPermission, UserRole } from "@shared/types";
import Document from "~/models/Document";
import Share from "~/models/Share";
import Avatar from "~/components/Avatar";
import { AvatarSize } from "~/components/Avatar/Avatar";
import ButtonSmall from "~/components/ButtonSmall";
import CopyToClipboard from "~/components/CopyToClipboard";
import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip";
import { createAction } from "~/actions";
import { UserSection } from "~/actions/sections";
import useActionContext from "~/hooks/useActionContext";
import useBoolean from "~/hooks/useBoolean";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useKeyDown from "~/hooks/useKeyDown";
import useMobile from "~/hooks/useMobile";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { hover } from "~/styles";
import { documentPath, urlify } from "~/utils/routeHelpers";
import ButtonSmall from "../ButtonSmall";
import Input, { NativeInput } from "../Input";
import NudeButton from "../NudeButton";
import Tooltip from "../Tooltip";
import { Separator, Wrapper, presence } from "../components";
import { SearchInput } from "../components/SearchInput";
import { Suggestions } from "../components/Suggestions";
import DocumentMembersList from "./DocumentMemberList";
import { OtherAccess } from "./OtherAccess";
import PublicAccess from "./PublicAccess";
import { UserSuggestions } from "./UserSuggestions";
type Props = {
/** The document to share. */
@@ -46,29 +43,6 @@ type Props = {
visible: boolean;
};
const presence = {
initial: {
opacity: 0,
width: 0,
marginRight: 0,
},
animate: {
opacity: 1,
width: "auto",
marginRight: 8,
transition: {
type: "spring",
duration: 0.2,
bounce: 0,
},
},
exit: {
opacity: 0,
width: 0,
marginRight: 0,
},
};
function SharePopover({
document,
share,
@@ -79,13 +53,13 @@ function SharePopover({
const team = useCurrentTeam();
const { t } = useTranslation();
const can = usePolicy(document);
const inputRef = React.useRef<HTMLInputElement>(null);
const linkButtonRef = React.useRef<HTMLButtonElement>(null);
const context = useActionContext();
const [hasRendered, setHasRendered] = React.useState(visible);
const { users, userMemberships } = useStores();
const isMobile = useMobile();
const [query, setQuery] = React.useState("");
const [picker, showPicker, hidePicker] = useBoolean();
const timeout = React.useRef<ReturnType<typeof setTimeout>>();
const linkButtonRef = React.useRef<HTMLButtonElement>(null);
const [invitedInSession, setInvitedInSession] = React.useState<string[]>([]);
const [pendingIds, setPendingIds] = React.useState<string[]>([]);
const collectionSharingDisabled = document.collection?.sharing === false;
@@ -111,6 +85,7 @@ function SharePopover({
React.useEffect(() => {
if (visible) {
void document.share();
setHasRendered(true);
}
}, [document, hidePicker, visible]);
@@ -143,8 +118,6 @@ function SharePopover({
};
}, [onRequestClose, t]);
const context = useActionContext();
const inviteAction = React.useMemo(
() =>
createAction({
@@ -188,13 +161,17 @@ function SharePopover({
);
if (usersInvited.length === 1) {
const user = usersInvited[0];
toast.message(
t("{{ userName }} was invited to the document", {
userName: usersInvited[0].name,
})
userName: user.name,
}),
{
icon: <Avatar model={user} size={AvatarSize.Toast} />,
}
);
} else {
toast.message(
toast.success(
t("{{ count }} people invited to the document", {
count: pendingIds.length,
})
@@ -225,13 +202,6 @@ function SharePopover({
[showPicker, setQuery]
);
const focusInput = React.useCallback(() => {
if (!picker) {
inputRef.current?.focus();
showPicker();
}
}, [picker, showPicker]);
const handleAddPendingId = React.useCallback(
(id: string) => {
setPendingIds((prev) => [...prev, id]);
@@ -246,10 +216,23 @@ function SharePopover({
[setPendingIds]
);
if (!hasRendered) {
return null;
}
const backButton = (
<>
{picker && (
<NudeButton key="back" as={m.button} {...presence} onClick={hidePicker}>
<NudeButton
key="back"
as={m.button}
{...presence}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
hidePicker();
}}
>
<BackIcon />
</NudeButton>
)}
@@ -282,44 +265,19 @@ function SharePopover({
return (
<Wrapper>
{can.manageUsers &&
(isMobile ? (
<Flex align="center" style={{ marginBottom: 12 }} auto>
{backButton}
<Input
key="input"
placeholder={`${t("Invite")}`}
value={query}
onChange={handleQuery}
onClick={showPicker}
autoFocus
margin={0}
flex
>
{rightButton}
</Input>
</Flex>
) : (
<HeaderInput align="center" onClick={focusInput}>
<AnimatePresence initial={false}>
{backButton}
<NativeInput
key="input"
ref={inputRef}
placeholder={`${t("Invite")}`}
value={query}
onChange={handleQuery}
onClick={showPicker}
style={{ padding: "6px 0" }}
/>
{rightButton}
</AnimatePresence>
</HeaderInput>
))}
{can.manageUsers && (
<SearchInput
onChange={handleQuery}
onClick={showPicker}
query={query}
back={backButton}
action={rightButton}
/>
)}
{picker && (
<div>
<UserSuggestions
<Suggestions
document={document}
query={query}
pendingIds={pendingIds}
@@ -353,42 +311,4 @@ function SharePopover({
);
}
// TODO: Temp until Button/NudeButton styles are normalized
const Wrapper = styled.div`
${NudeButton}:${hover},
${NudeButton}[aria-expanded="true"] {
background: ${(props) => darken(0.05, props.theme.buttonNeutralBackground)};
}
`;
const Separator = styled.div`
border-top: 1px dashed ${s("divider")};
margin: 12px 0;
`;
const HeaderInput = styled(Flex)`
position: sticky;
z-index: 1;
top: 0;
background: ${s("menuBackground")};
color: ${s("textTertiary")};
border-bottom: 1px solid ${s("inputBorder")};
padding: 0 24px 12px;
margin-top: 0;
margin-left: -24px;
margin-right: -24px;
margin-bottom: 12px;
cursor: text;
&:before {
content: "";
position: absolute;
left: 0;
right: 0;
top: -20px;
height: 20px;
background: ${s("menuBackground")};
}
`;
export default observer(SharePopover);

View File

@@ -0,0 +1,21 @@
import { PlusIcon } from "outline-icons";
import styled from "styled-components";
import BaseListItem from "~/components/List/Item";
import { hover } from "~/styles";
export const InviteIcon = styled(PlusIcon)`
opacity: 0;
`;
export const ListItem = styled(BaseListItem).attrs({
small: true,
border: false,
})`
margin: 0 -16px;
padding: 6px 16px;
border-radius: 8px;
&: ${hover} ${InviteIcon} {
opacity: 1;
}
`;

View File

@@ -0,0 +1,63 @@
import { AnimatePresence } from "framer-motion";
import * as React from "react";
import { useTranslation } from "react-i18next";
import Flex from "~/components/Flex";
import useMobile from "~/hooks/useMobile";
import Input, { NativeInput } from "../../Input";
import { HeaderInput } from "../components";
type Props = {
query: string;
onChange: React.ChangeEventHandler;
onClick: React.MouseEventHandler;
back: React.ReactNode;
action: React.ReactNode;
};
export function SearchInput({ onChange, onClick, query, back, action }: Props) {
const { t } = useTranslation();
const inputRef = React.useRef<HTMLInputElement>(null);
const isMobile = useMobile();
const focusInput = React.useCallback(
(event) => {
inputRef.current?.focus();
onClick(event);
},
[onClick]
);
return isMobile ? (
<Flex align="center" style={{ marginBottom: 12 }} auto>
{back}
<Input
key="input"
placeholder={`${t("Add or invite")}`}
value={query}
onChange={onChange}
onClick={onClick}
autoFocus
margin={0}
flex
>
{action}
</Input>
</Flex>
) : (
<HeaderInput align="center" onClick={focusInput}>
<AnimatePresence initial={false}>
{back}
<NativeInput
key="input"
ref={inputRef}
placeholder={`${t("Add or invite")}`}
value={query}
onChange={onChange}
onClick={onClick}
style={{ padding: "6px 0" }}
/>
{action}
</AnimatePresence>
</HeaderInput>
);
}

View File

@@ -1,21 +1,24 @@
import { isEmail } from "class-validator";
import { observer } from "mobx-react";
import { CheckmarkIcon, CloseIcon } from "outline-icons";
import { CheckmarkIcon, CloseIcon, GroupIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import styled, { useTheme } from "styled-components";
import Squircle from "@shared/components/Squircle";
import { s } from "@shared/styles";
import { stringToColor } from "@shared/utils/color";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import Group from "~/models/Group";
import User from "~/models/User";
import Avatar from "~/components/Avatar";
import { AvatarSize, IAvatar } from "~/components/Avatar/Avatar";
import Empty from "~/components/Empty";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import useThrottledCallback from "~/hooks/useThrottledCallback";
import { hover } from "~/styles";
import Avatar from "../Avatar";
import { AvatarSize, IAvatar } from "../Avatar/Avatar";
import Empty from "../Empty";
import { InviteIcon, StyledListItem } from "./MemberListItem";
import { InviteIcon, ListItem } from "./ListItem";
type Suggestion = IAvatar & {
id: string;
@@ -23,7 +26,9 @@ type Suggestion = IAvatar & {
type Props = {
/** The document being shared. */
document: Document;
document?: Document;
/** The collection being shared. */
collection?: Collection;
/** The search query to filter users by. */
query: string;
/** A list of pending user ids that have not yet been invited. */
@@ -32,18 +37,32 @@ type Props = {
addPendingId: (id: string) => void;
/** Callback to remove a user from the pending list. */
removePendingId: (id: string) => void;
/** Show group suggestions. */
showGroups?: boolean;
};
export const UserSuggestions = observer(
({ document, query, pendingIds, addPendingId, removePendingId }: Props) => {
const { users } = useStores();
export const Suggestions = observer(
({
document,
collection,
query,
pendingIds,
addPendingId,
removePendingId,
showGroups,
}: Props) => {
const { users, groups } = useStores();
const { t } = useTranslation();
const user = useCurrentUser();
const theme = useTheme();
const fetchUsersByQuery = useThrottledCallback(
(params) => users.fetchPage({ query: params.query }),
250
);
const fetchUsersByQuery = useThrottledCallback((params) => {
void users.fetchPage({ query: params.query });
if (showGroups) {
void groups.fetchPage({ query: params.query });
}
}, 250);
const getSuggestionForEmail = React.useCallback(
(email: string) => ({
@@ -58,21 +77,30 @@ export const UserSuggestions = observer(
);
const suggestions = React.useMemo(() => {
const filtered: Suggestion[] = users
.notInDocument(document.id, query)
.filter((u) => u.id !== user.id && !u.isSuspended);
const filtered: Suggestion[] = (
document
? users.notInDocument(document.id, query)
: collection
? users.notInCollection(collection.id, query)
: users.orderedData
).filter((u) => u.id !== user.id && !u.isSuspended);
if (isEmail(query)) {
filtered.push(getSuggestionForEmail(query));
}
if (collection?.id) {
return [...groups.notInCollection(collection.id, query), ...filtered];
}
return filtered;
}, [
getSuggestionForEmail,
users,
users.orderedData,
document.id,
document.members,
document?.id,
document?.members,
collection?.id,
user.id,
query,
t,
@@ -82,19 +110,32 @@ export const UserSuggestions = observer(
() =>
pendingIds
.map((id) =>
isEmail(id) ? getSuggestionForEmail(id) : users.get(id)
isEmail(id)
? getSuggestionForEmail(id)
: users.get(id) ?? groups.get(id)
)
.filter(Boolean) as User[],
[users, getSuggestionForEmail, pendingIds]
);
React.useEffect(() => {
if (query) {
void fetchUsersByQuery(query);
}
void fetchUsersByQuery(query);
}, [query, fetchUsersByQuery]);
function getListItemProps(suggestion: User) {
function getListItemProps(suggestion: User | Group) {
if (suggestion instanceof Group) {
return {
title: suggestion.name,
subtitle: t("{{ count }} member", {
count: suggestion.memberCount,
}),
image: (
<Squircle color={theme.text} size={AvatarSize.Medium}>
<GroupIcon color={theme.background} size={16} />
</Squircle>
),
};
}
return {
title: suggestion.name,
subtitle: suggestion.email
@@ -135,7 +176,7 @@ export const UserSuggestions = observer(
{pending.length > 0 &&
(suggestionsWithPending.length > 0 || isEmpty) && <Separator />}
{suggestionsWithPending.map((suggestion) => (
<StyledListItem
<ListItem
{...getListItemProps(suggestion as User)}
key={suggestion.id}
onClick={() => addPendingId(suggestion.id)}
@@ -156,7 +197,7 @@ const RemoveIcon = styled(CloseIcon)`
display: none;
`;
const PendingListItem = styled(StyledListItem)`
const PendingListItem = styled(ListItem)`
&: ${hover} {
${InvitedIcon} {
display: none;

View File

@@ -0,0 +1,67 @@
import { darken } from "polished";
import styled from "styled-components";
import Flex from "@shared/components/Flex";
import { s } from "@shared/styles";
import NudeButton from "~/components/NudeButton";
import { hover } from "~/styles";
// TODO: Temp until Button/NudeButton styles are normalized
export const Wrapper = styled.div`
${NudeButton}:${hover},
${NudeButton}[aria-expanded="true"] {
background: ${(props) => darken(0.05, props.theme.buttonNeutralBackground)};
}
`;
export const Separator = styled.div`
border-top: 1px dashed ${s("divider")};
margin: 12px 0;
`;
export const HeaderInput = styled(Flex)`
position: sticky;
z-index: 1;
top: 0;
background: ${s("menuBackground")};
color: ${s("textTertiary")};
border-bottom: 1px solid ${s("inputBorder")};
padding: 0 24px 12px;
margin-top: 0;
margin-left: -24px;
margin-right: -24px;
margin-bottom: 12px;
cursor: text;
&:before {
content: "";
position: absolute;
left: 0;
right: 0;
top: -20px;
height: 20px;
background: ${s("menuBackground")};
}
`;
export const presence = {
initial: {
opacity: 0,
width: 0,
marginRight: 0,
},
animate: {
opacity: 1,
width: "auto",
marginRight: 8,
transition: {
type: "spring",
duration: 0.2,
bounce: 0,
},
},
exit: {
opacity: 0,
width: 0,
marginRight: 0,
},
};

View File

@@ -1,6 +1,9 @@
import { observable } from "mobx";
import { CollectionPermission } from "@shared/types";
import Collection from "./Collection";
import User from "./User";
import Model from "./base/Model";
import Relation from "./decorators/Relation";
class Membership extends Model {
static modelName = "Membership";
@@ -9,8 +12,14 @@ class Membership extends Model {
userId: string;
@Relation(() => User, { onDelete: "cascade" })
user: User;
collectionId: string;
@Relation(() => Collection, { onDelete: "cascade" })
collection: Collection;
@observable
permission: CollectionPermission;
}

View File

@@ -1,4 +1,5 @@
import { observable } from "mobx";
import Collection from "./Collection";
import Document from "./Document";
import User from "./User";
import Model from "./base/Model";
@@ -25,6 +26,15 @@ class Share extends Model {
@Relation(() => Document, { onDelete: "cascade" })
document: Document;
/** The collection ID that is shared. */
@Field
@observable
collectionId: string;
/** The collection that is shared. */
@Relation(() => Collection, { onDelete: "cascade" })
collection: Collection;
@Field
@observable
urlId: string;

View File

@@ -11,6 +11,7 @@ import Text from "~/components/Text";
import { editCollectionPermissions } from "~/actions/definitions/collections";
import useActionContext from "~/hooks/useActionContext";
import usePolicy from "~/hooks/usePolicy";
import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
import { newDocumentPath } from "~/utils/routeHelpers";
type Props = {
@@ -48,14 +49,16 @@ function EmptyCollection({ collection }: Props) {
{t("Create a document")}
</Button>
</Link>
<Button
action={editCollectionPermissions}
context={context}
hideOnActionDisabled
neutral
>
{t("Manage permissions")}
</Button>
{FeatureFlags.isEnabled(Feature.newCollectionSharing) ? null : (
<Button
action={editCollectionPermissions}
context={context}
hideOnActionDisabled
neutral
>
{t("Manage permissions")}
</Button>
)}
</Empty>
)}
</Centered>

View File

@@ -2,10 +2,8 @@ import sortBy from "lodash/sortBy";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { PAGINATION_SYMBOL } from "~/stores/base/Store";
import Collection from "~/models/Collection";
import User from "~/models/User";
import Avatar from "~/components/Avatar";
import Facepile from "~/components/Facepile";
import Fade from "~/components/Fade";
@@ -14,6 +12,7 @@ import { editCollectionPermissions } from "~/actions/definitions/collections";
import useActionContext from "~/hooks/useActionContext";
import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
type Props = {
collection: Collection;
@@ -72,7 +71,11 @@ const MembershipPreview = ({ collection, limit = 8 }: Props) => {
return (
<NudeButton
context={context}
action={editCollectionPermissions}
action={
FeatureFlags.isEnabled(Feature.newCollectionSharing)
? undefined
: editCollectionPermissions
}
tooltip={{
content:
usersCount > 0
@@ -104,16 +107,11 @@ const MembershipPreview = ({ collection, limit = 8 }: Props) => {
users={sortBy(collectionUsers, "lastActiveAt")}
overflow={overflow}
limit={limit}
renderAvatar={(user) => <StyledAvatar model={user} size={32} />}
renderAvatar={(user) => <Avatar model={user} size={32} />}
/>
</Fade>
</NudeButton>
);
};
const StyledAvatar = styled(Avatar)<{ model: User }>`
transition: opacity 250ms ease-in-out;
opacity: ${(props) => (props.model.isRecentlyActive ? 1 : 0.5)};
`;
export default observer(MembershipPreview);

View File

@@ -0,0 +1,60 @@
import { observer } from "mobx-react";
import { GlobeIcon, PadlockIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
import Collection from "~/models/Collection";
import Button from "~/components/Button";
import Popover from "~/components/Popover";
import SharePopover from "~/components/Sharing/Collection/SharePopover";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useStores from "~/hooks/useStores";
type Props = {
/** Collection being shared */
collection: Collection;
};
function ShareButton({ collection }: Props) {
const { t } = useTranslation();
const { shares } = useStores();
const team = useCurrentTeam();
const share = shares.getByCollectionId(collection.id);
const isPubliclyShared =
team.sharing !== false && collection?.sharing !== false && share?.published;
const popover = usePopoverState({
gutter: 0,
placement: "bottom-end",
unstable_fixed: true,
});
const icon = isPubliclyShared ? (
<GlobeIcon />
) : collection.permission ? undefined : (
<PadlockIcon />
);
return (
<>
<PopoverDisclosure {...popover}>
{(props) => (
<Button icon={icon} neutral {...props}>
{t("Share")}
</Button>
)}
</PopoverDisclosure>
<Popover {...popover} aria-label={t("Share")} width={400}>
<SharePopover
collection={collection}
share={share}
onRequestClose={popover.hide}
visible={popover.visible}
/>
</Popover>
</>
);
}
export default observer(ShareButton);

View File

@@ -15,6 +15,7 @@ import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import Collection from "~/models/Collection";
import Search from "~/scenes/Search";
import { Action } from "~/components/Actions";
import Badge from "~/components/Badge";
import CenteredContent from "~/components/CenteredContent";
import CollectionDescription from "~/components/CollectionDescription";
@@ -34,11 +35,13 @@ import useCommandBarActions from "~/hooks/useCommandBarActions";
import useLastVisitedPath from "~/hooks/useLastVisitedPath";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
import { collectionPath, updateCollectionPath } from "~/utils/routeHelpers";
import Actions from "./Collection/Actions";
import DropToImport from "./Collection/DropToImport";
import Empty from "./Collection/Empty";
import MembershipPreview from "./Collection/MembershipPreview";
import Actions from "./components/Actions";
import DropToImport from "./components/DropToImport";
import Empty from "./components/Empty";
import MembershipPreview from "./components/MembershipPreview";
import ShareButton from "./components/ShareButton";
function CollectionScene() {
const params = useParams<{ id?: string }>();
@@ -142,6 +145,10 @@ function CollectionScene() {
actions={
<>
<MembershipPreview collection={collection} />
<Action>
{FeatureFlags.isEnabled(Feature.newCollectionSharing) &&
can.update && <ShareButton collection={collection} />}
</Action>
<Actions collection={collection} />
</>
}
@@ -159,16 +166,17 @@ function CollectionScene() {
<HeadingWithIcon>
<HeadingIcon collection={collection} size={40} expanded />
{collection.name}
{collection.isPrivate && (
<Tooltip
content={t(
"This collection is only visible to those given access"
)}
placement="bottom"
>
<Badge>{t("Private")}</Badge>
</Tooltip>
)}
{collection.isPrivate &&
!FeatureFlags.isEnabled(Feature.newCollectionSharing) && (
<Tooltip
content={t(
"This collection is only visible to those given access"
)}
placement="bottom"
>
<Badge>{t("Private")}</Badge>
</Tooltip>
)}
</HeadingWithIcon>
<PinnedDocuments

View File

@@ -6,11 +6,12 @@ import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
import Document from "~/models/Document";
import Button from "~/components/Button";
import Popover from "~/components/Popover";
import SharePopover from "~/components/Sharing";
import SharePopover from "~/components/Sharing/Document";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useStores from "~/hooks/useStores";
type Props = {
/** Document being shared */
document: Document;
};
@@ -32,15 +33,13 @@ function ShareButton({ document }: Props) {
unstable_fixed: true,
});
const icon = isPubliclyShared ? <GlobeIcon /> : undefined;
return (
<>
<PopoverDisclosure {...popover}>
{(props) => (
<Button
icon={isPubliclyShared ? <GlobeIcon /> : undefined}
neutral
{...props}
>
<Button icon={icon} neutral {...props}>
{t("Share")} {domain && <>&middot; {domain}</>}
</Button>
)}

View File

@@ -76,4 +76,7 @@ export default class CollectionGroupMembershipsStore extends Store<CollectionGro
groupId,
});
}
inCollection = (collectionId: string) =>
this.orderedData.filter((cgm) => cgm.collectionId === collectionId);
}

View File

@@ -82,4 +82,9 @@ export default class MembershipsStore extends Store<Membership> {
}
});
};
inCollection = (collectionId: string) =>
this.orderedData.filter(
(membership) => membership.collectionId === collectionId
);
}

View File

@@ -104,6 +104,9 @@ export default class SharesStore extends Store<Share> {
return undefined;
};
getByCollectionId = (collectionId: string): Share | null | undefined =>
find(this.orderedData, (share) => share.collectionId === collectionId);
getByDocumentId = (documentId: string): Share | null | undefined =>
find(this.orderedData, (share) => share.documentId === documentId);
}

View File

@@ -221,6 +221,7 @@ export const EmptySelectValue = "__empty__";
export type Permission = {
label: string;
value: CollectionPermission | DocumentPermission | typeof EmptySelectValue;
divider?: boolean;
};
// TODO: Can we make this type driven by the @Field decorator

43
app/utils/FeatureFlags.ts Normal file
View File

@@ -0,0 +1,43 @@
import { observable } from "mobx";
import Storage from "@shared/utils/Storage";
export enum Feature {
/** New collection permissions UI */
newCollectionSharing = "newCollectionSharing",
}
/**
* A simple feature flagging system that stores flags in browser storage.
*/
export class FeatureFlags {
public static isEnabled(flag: Feature) {
// init on first read
if (this.initalized === false) {
this.cache = new Set();
for (const key of Object.values(Feature)) {
const value = Storage.get(key);
if (value === true) {
this.cache.add(key);
}
}
this.initalized = true;
}
return this.cache.has(flag);
}
public static enable(flag: Feature) {
this.cache.add(flag);
Storage.set(flag, true);
}
public static disable(flag: Feature) {
this.cache.delete(flag);
Storage.set(flag, false);
}
@observable
private static cache: Set<Feature> = new Set();
private static initalized = false;
}

View File

@@ -264,6 +264,20 @@
"Documents": "Documents",
"Results": "Results",
"No results for {{query}}": "No results for {{query}}",
"Admin": "Admin",
"Invite": "Invite",
"{{ userName }} was added to the collection": "{{ userName }} was added to the collection",
"{{ count }} people added to the collection": "{{ count }} people added to the collection",
"{{ count }} people added to the collection_plural": "{{ count }} people added to the collection",
"{{ count }} people and {{ count2 }} groups added to the collection": "{{ count }} people and {{ count2 }} groups added to the collection",
"{{ count }} people and {{ count2 }} groups added to the collection_plural": "{{ count }} people and {{ count2 }} groups added to the collection",
"Add": "Add",
"All members": "All members",
"Everyone in the workspace": "Everyone in the workspace",
"Add or invite": "Add or invite",
"Viewer": "Viewer",
"Editor": "Editor",
"No matches": "No matches",
"{{ userName }} was removed from the document": "{{ userName }} was removed from the document",
"Could not remove user": "Could not remove user",
"Permissions for {{ userName }} updated": "Permissions for {{ userName }} updated",
@@ -271,11 +285,7 @@
"Has access through <2>parent</2>": "Has access through <2>parent</2>",
"Suspended": "Suspended",
"Invited": "Invited",
"Viewer": "Viewer",
"Editor": "Editor",
"Leave": "Leave",
"All members": "All members",
"Everyone in the workspace": "Everyone in the workspace",
"Can view": "Can view",
"Everyone in the collection": "Everyone in the collection",
"You have full access": "You have full access",
@@ -293,11 +303,9 @@
"Allow anyone with the link to access": "Allow anyone with the link to access",
"Publish to internet": "Publish to internet",
"Nested documents are not shared on the web. Toggle sharing to enable access, this will be the default behavior in the future": "Nested documents are not shared on the web. Toggle sharing to enable access, this will be the default behavior in the future",
"Invite": "Invite",
"{{ userName }} was invited to the document": "{{ userName }} was invited to the document",
"{{ count }} people invited to the document": "{{ count }} people invited to the document",
"{{ count }} people invited to the document_plural": "{{ count }} people invited to the document",
"No matches": "No matches",
"Logo": "Logo",
"Move document": "Move document",
"New doc": "New doc",
@@ -485,12 +493,6 @@
"API token created": "API token created",
"Name your token something that will help you to remember it's use in the future, for example \"local development\", \"production\", or \"continuous integration\".": "Name your token something that will help you to remember it's use in the future, for example \"local development\", \"production\", or \"continuous integration\".",
"The document archive is empty at the moment.": "The document archive is empty at the moment.",
"This collection is only visible to those given access": "This collection is only visible to those given access",
"Private": "Private",
"Recently updated": "Recently updated",
"Recently published": "Recently published",
"Least recently updated": "Least recently updated",
"AZ": "AZ",
"Collection menu": "Collection menu",
"Drop documents to import": "Drop documents to import",
"<em>{{ collectionName }}</em> doesnt contain any\n documents yet.": "<em>{{ collectionName }}</em> doesnt contain any\n documents yet.",
@@ -505,6 +507,12 @@
"{{ usersCount }} users with access_plural": "{{ usersCount }} users with access",
"{{ groupsCount }} groups with access": "{{ groupsCount }} group with access",
"{{ groupsCount }} groups with access_plural": "{{ groupsCount }} groups with access",
"This collection is only visible to those given access": "This collection is only visible to those given access",
"Private": "Private",
"Recently updated": "Recently updated",
"Recently published": "Recently published",
"Least recently updated": "Least recently updated",
"AZ": "AZ",
"{{ groupName }} was added to the collection": "{{ groupName }} was added to the collection",
"Could not add user": "Could not add user",
"Cant find the group youre looking for?": "Cant find the group youre looking for?",
@@ -513,8 +521,6 @@
"Search groups": "Search groups",
"No groups matching your search": "No groups matching your search",
"No groups left to add": "No groups left to add",
"Add": "Add",
"{{ userName }} was added to the collection": "{{ userName }} was added to the collection",
"Need to add someone whos not on the team yet?": "Need to add someone whos not on the team yet?",
"Invite people to {{ teamName }}": "Invite people to {{ teamName }}",
"Ask an admin to invite them first": "Ask an admin to invite them first",
@@ -522,7 +528,6 @@
"Search people": "Search people",
"No people matching your search": "No people matching your search",
"No people left to add": "No people left to add",
"Admin": "Admin",
"Active <1></1> ago": "Active <1></1> ago",
"Never signed in": "Never signed in",
"{{ userName }} was removed from the collection": "{{ userName }} was removed from the collection",

View File

@@ -182,7 +182,7 @@ export const buildDarkTheme = (input: Partial<Colors>): DefaultTheme => {
textDiffInsertedBackground: "rgba(63,185,80,0.3)",
textDiffDeleted: darken(0.1, colors.almostWhite),
textDiffDeletedBackground: "rgba(248,81,73,0.15)",
placeholder: colors.slateDark,
placeholder: "#596673",
sidebarBackground: colors.veryDarkBlue,
sidebarActiveBackground: lighten(0.02, colors.almostBlack),
sidebarControlHoverBackground: colors.white10,