Update collection permissions UI (#6917)
This commit is contained in:
@@ -20,6 +20,7 @@ import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
|
|||||||
import { createAction } from "~/actions";
|
import { createAction } from "~/actions";
|
||||||
import { CollectionSection } from "~/actions/sections";
|
import { CollectionSection } from "~/actions/sections";
|
||||||
import { setPersistedState } from "~/hooks/usePersistedState";
|
import { setPersistedState } from "~/hooks/usePersistedState";
|
||||||
|
import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
|
||||||
import history from "~/utils/history";
|
import history from "~/utils/history";
|
||||||
import { searchPath } from "~/utils/routeHelpers";
|
import { searchPath } from "~/utils/routeHelpers";
|
||||||
|
|
||||||
@@ -99,7 +100,8 @@ export const editCollectionPermissions = createAction({
|
|||||||
icon: <PadlockIcon />,
|
icon: <PadlockIcon />,
|
||||||
visible: ({ stores, activeCollectionId }) =>
|
visible: ({ stores, activeCollectionId }) =>
|
||||||
!!activeCollectionId &&
|
!!activeCollectionId &&
|
||||||
stores.policies.abilities(activeCollectionId).update,
|
stores.policies.abilities(activeCollectionId).update &&
|
||||||
|
!FeatureFlags.isEnabled(Feature.newCollectionSharing),
|
||||||
perform: ({ t, activeCollectionId }) => {
|
perform: ({ t, activeCollectionId }) => {
|
||||||
if (!activeCollectionId) {
|
if (!activeCollectionId) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import copy from "copy-to-clipboard";
|
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 * as React from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { createAction } from "~/actions";
|
import { createAction } from "~/actions";
|
||||||
import { DeveloperSection } from "~/actions/sections";
|
import { DeveloperSection } from "~/actions/sections";
|
||||||
import env from "~/env";
|
import env from "~/env";
|
||||||
import { client } from "~/utils/ApiClient";
|
import { client } from "~/utils/ApiClient";
|
||||||
|
import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
|
||||||
import Logger from "~/utils/Logger";
|
import Logger from "~/utils/Logger";
|
||||||
import { deleteAllDatabases } from "~/utils/developer";
|
import { deleteAllDatabases } from "~/utils/developer";
|
||||||
import history from "~/utils/history";
|
import history from "~/utils/history";
|
||||||
@@ -104,7 +111,7 @@ export const createToast = createAction({
|
|||||||
name: "Create toast",
|
name: "Create toast",
|
||||||
section: DeveloperSection,
|
section: DeveloperSection,
|
||||||
visible: () => env.ENVIRONMENT === "development",
|
visible: () => env.ENVIRONMENT === "development",
|
||||||
perform: async () => {
|
perform: () => {
|
||||||
toast.message("Hello world", {
|
toast.message("Hello world", {
|
||||||
duration: 30000,
|
duration: 30000,
|
||||||
});
|
});
|
||||||
@@ -115,7 +122,7 @@ export const toggleDebugLogging = createAction({
|
|||||||
name: ({ t }) => t("Toggle debug logging"),
|
name: ({ t }) => t("Toggle debug logging"),
|
||||||
icon: <ToolsIcon />,
|
icon: <ToolsIcon />,
|
||||||
section: DeveloperSection,
|
section: DeveloperSection,
|
||||||
perform: async ({ t }) => {
|
perform: ({ t }) => {
|
||||||
Logger.debugLoggingEnabled = !Logger.debugLoggingEnabled;
|
Logger.debugLoggingEnabled = !Logger.debugLoggingEnabled;
|
||||||
toast.message(
|
toast.message(
|
||||||
Logger.debugLoggingEnabled
|
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({
|
export const developer = createAction({
|
||||||
name: ({ t }) => t("Development"),
|
name: ({ t }) => t("Development"),
|
||||||
keywords: "debug",
|
keywords: "debug",
|
||||||
@@ -133,10 +164,11 @@ export const developer = createAction({
|
|||||||
section: DeveloperSection,
|
section: DeveloperSection,
|
||||||
children: [
|
children: [
|
||||||
copyId,
|
copyId,
|
||||||
clearIndexedDB,
|
|
||||||
toggleDebugLogging,
|
toggleDebugLogging,
|
||||||
|
toggleFeatureFlag,
|
||||||
createToast,
|
createToast,
|
||||||
createTestUsers,
|
createTestUsers,
|
||||||
|
clearIndexedDB,
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ import DocumentPublish from "~/scenes/DocumentPublish";
|
|||||||
import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInTrash";
|
import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInTrash";
|
||||||
import DocumentTemplatizeDialog from "~/components/DocumentTemplatizeDialog";
|
import DocumentTemplatizeDialog from "~/components/DocumentTemplatizeDialog";
|
||||||
import DuplicateDialog from "~/components/DuplicateDialog";
|
import DuplicateDialog from "~/components/DuplicateDialog";
|
||||||
import SharePopover from "~/components/Sharing";
|
import SharePopover from "~/components/Sharing/Document";
|
||||||
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
|
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
|
||||||
import { createAction } from "~/actions";
|
import { createAction } from "~/actions";
|
||||||
import { DocumentSection, TrashSection } from "~/actions/sections";
|
import { DocumentSection, TrashSection } from "~/actions/sections";
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import Switch from "~/components/Switch";
|
|||||||
import Text from "~/components/Text";
|
import Text from "~/components/Text";
|
||||||
import useBoolean from "~/hooks/useBoolean";
|
import useBoolean from "~/hooks/useBoolean";
|
||||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||||
|
import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
|
||||||
|
|
||||||
export interface FormData {
|
export interface FormData {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -138,16 +139,18 @@ export const CollectionForm = observer(function CollectionForm_({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{team.sharing && !collection && (
|
{team.sharing &&
|
||||||
<Switch
|
(!collection ||
|
||||||
id="sharing"
|
FeatureFlags.isEnabled(Feature.newCollectionSharing)) && (
|
||||||
label={t("Public document sharing")}
|
<Switch
|
||||||
note={t(
|
id="sharing"
|
||||||
"Allow documents within this collection to be shared publicly on the internet."
|
label={t("Public document sharing")}
|
||||||
)}
|
note={t(
|
||||||
{...register("sharing")}
|
"Allow documents within this collection to be shared publicly on the internet."
|
||||||
/>
|
)}
|
||||||
)}
|
{...register("sharing")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Flex justify="flex-end">
|
<Flex justify="flex-end">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -3,21 +3,30 @@ import { useTranslation } from "react-i18next";
|
|||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import { s } from "@shared/styles";
|
import { s } from "@shared/styles";
|
||||||
import InputSelect, { Props as SelectProps } from "~/components/InputSelect";
|
import InputSelect, { Props as SelectProps } from "~/components/InputSelect";
|
||||||
import { Permission } from "~/types";
|
import { EmptySelectValue, Permission } from "~/types";
|
||||||
|
|
||||||
export default function InputMemberPermissionSelect(
|
export default function InputMemberPermissionSelect(
|
||||||
props: Partial<SelectProps> & { permissions: Permission[] }
|
props: Partial<SelectProps> & { permissions: Permission[] }
|
||||||
) {
|
) {
|
||||||
|
const { value, onChange, ...rest } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const handleChange = React.useCallback(
|
||||||
|
(value) => {
|
||||||
|
onChange?.(value === EmptySelectValue ? null : value);
|
||||||
|
},
|
||||||
|
[onChange]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
label={t("Permissions")}
|
label={t("Permissions")}
|
||||||
options={props.permissions}
|
options={props.permissions}
|
||||||
ariaLabel={t("Permissions")}
|
ariaLabel={t("Permissions")}
|
||||||
|
onChange={handleChange}
|
||||||
|
value={value || EmptySelectValue}
|
||||||
labelHidden
|
labelHidden
|
||||||
nude
|
nude
|
||||||
{...props}
|
{...rest}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,12 +23,14 @@ import {
|
|||||||
Placement,
|
Placement,
|
||||||
} from "./ContextMenu";
|
} from "./ContextMenu";
|
||||||
import { MenuAnchorCSS } from "./ContextMenu/MenuItem";
|
import { MenuAnchorCSS } from "./ContextMenu/MenuItem";
|
||||||
|
import Separator from "./ContextMenu/Separator";
|
||||||
import { LabelText } from "./Input";
|
import { LabelText } from "./Input";
|
||||||
|
|
||||||
export type Option = {
|
export type Option = {
|
||||||
label: string | JSX.Element;
|
label: string | JSX.Element;
|
||||||
value: string;
|
value: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
divider?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Props = {
|
export type Props = {
|
||||||
@@ -47,6 +49,7 @@ export type Props = {
|
|||||||
/** @deprecated Removing soon, do not use. */
|
/** @deprecated Removing soon, do not use. */
|
||||||
note?: React.ReactNode;
|
note?: React.ReactNode;
|
||||||
onChange?: (value: string | null) => void;
|
onChange?: (value: string | null) => void;
|
||||||
|
style?: React.CSSProperties;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface InputSelectRef {
|
export interface InputSelectRef {
|
||||||
@@ -247,16 +250,19 @@ const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
|
|||||||
const isSelected = select.selectedValue === opt.value;
|
const isSelected = select.selectedValue === opt.value;
|
||||||
const Icon = isSelected ? CheckmarkIcon : Spacer;
|
const Icon = isSelected ? CheckmarkIcon : Spacer;
|
||||||
return (
|
return (
|
||||||
<StyledSelectOption
|
<>
|
||||||
{...select}
|
{opt.divider && <Separator />}
|
||||||
value={opt.value}
|
<StyledSelectOption
|
||||||
key={opt.value}
|
{...select}
|
||||||
ref={isSelected ? selectedRef : undefined}
|
value={opt.value}
|
||||||
>
|
key={opt.value}
|
||||||
<Icon />
|
ref={isSelected ? selectedRef : undefined}
|
||||||
|
>
|
||||||
{labelForOption(opt)}
|
<Icon />
|
||||||
</StyledSelectOption>
|
|
||||||
|
{labelForOption(opt)}
|
||||||
|
</StyledSelectOption>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
: null}
|
: null}
|
||||||
|
|||||||
176
app/components/Sharing/Collection/CollectionMemberList.tsx
Normal file
176
app/components/Sharing/Collection/CollectionMemberList.tsx
Normal 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);
|
||||||
340
app/components/Sharing/Collection/SharePopover.tsx
Normal file
340
app/components/Sharing/Collection/SharePopover.tsx
Normal 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);
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { t } from "i18next";
|
|
||||||
import orderBy from "lodash/orderBy";
|
import orderBy from "lodash/orderBy";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useHistory } from "react-router-dom";
|
import { useHistory } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Pagination } from "@shared/constants";
|
import { Pagination } from "@shared/constants";
|
||||||
@@ -13,29 +13,23 @@ import usePolicy from "~/hooks/usePolicy";
|
|||||||
import useRequest from "~/hooks/useRequest";
|
import useRequest from "~/hooks/useRequest";
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
import { homePath } from "~/utils/routeHelpers";
|
import { homePath } from "~/utils/routeHelpers";
|
||||||
import MemberListItem from "./MemberListItem";
|
import MemberListItem from "./DocumentMemberListItem";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
/** Document to which team members are supposed to be invited */
|
/** Document to which team members are supposed to be invited */
|
||||||
document: Document;
|
document: Document;
|
||||||
/** Children to be rendered before the list of members */
|
/** Children to be rendered before the list of members */
|
||||||
children?: React.ReactNode;
|
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[];
|
invitedInSession: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
function DocumentMembersList({ document, invitedInSession }: Props) {
|
function DocumentMembersList({ document, invitedInSession }: Props) {
|
||||||
const { users, userMemberships } = useStores();
|
const { userMemberships } = useStores();
|
||||||
const user = useCurrentUser();
|
const user = useCurrentUser();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const can = usePolicy(document);
|
const can = usePolicy(document);
|
||||||
|
const { t } = useTranslation();
|
||||||
const { loading: loadingTeamMembers, request: fetchTeamMembers } = useRequest(
|
|
||||||
React.useCallback(
|
|
||||||
() => users.fetchPage({ limit: Pagination.defaultLimit }),
|
|
||||||
[users]
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const { loading: loadingDocumentMembers, request: fetchDocumentMembers } =
|
const { loading: loadingDocumentMembers, request: fetchDocumentMembers } =
|
||||||
useRequest(
|
useRequest(
|
||||||
@@ -50,9 +44,8 @@ function DocumentMembersList({ document, invitedInSession }: Props) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
void fetchTeamMembers();
|
|
||||||
void fetchDocumentMembers();
|
void fetchDocumentMembers();
|
||||||
}, [fetchTeamMembers, fetchDocumentMembers]);
|
}, [fetchDocumentMembers]);
|
||||||
|
|
||||||
const handleRemoveUser = React.useCallback(
|
const handleRemoveUser = React.useCallback(
|
||||||
async (item) => {
|
async (item) => {
|
||||||
@@ -112,7 +105,7 @@ function DocumentMembersList({ document, invitedInSession }: Props) {
|
|||||||
[document.members, invitedInSession]
|
[document.members, invitedInSession]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (loadingTeamMembers || loadingDocumentMembers) {
|
if (loadingDocumentMembers) {
|
||||||
return <LoadingIndicator />;
|
return <LoadingIndicator />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { PlusIcon } from "outline-icons";
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
@@ -11,9 +10,8 @@ import UserMembership from "~/models/UserMembership";
|
|||||||
import Avatar from "~/components/Avatar";
|
import Avatar from "~/components/Avatar";
|
||||||
import { AvatarSize } from "~/components/Avatar/Avatar";
|
import { AvatarSize } from "~/components/Avatar/Avatar";
|
||||||
import InputMemberPermissionSelect from "~/components/InputMemberPermissionSelect";
|
import InputMemberPermissionSelect from "~/components/InputMemberPermissionSelect";
|
||||||
import ListItem from "~/components/List/Item";
|
|
||||||
import { hover } from "~/styles";
|
|
||||||
import { EmptySelectValue, Permission } from "~/types";
|
import { EmptySelectValue, Permission } from "~/types";
|
||||||
|
import { ListItem } from "../components/ListItem";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
user: User;
|
user: User;
|
||||||
@@ -24,7 +22,7 @@ type Props = {
|
|||||||
onUpdate?: (permission: DocumentPermission) => void;
|
onUpdate?: (permission: DocumentPermission) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const MemberListItem = ({
|
const DocumentMemberListItem = ({
|
||||||
user,
|
user,
|
||||||
membership,
|
membership,
|
||||||
onRemove,
|
onRemove,
|
||||||
@@ -54,7 +52,7 @@ const MemberListItem = ({
|
|||||||
value: DocumentPermission.ReadWrite,
|
value: DocumentPermission.ReadWrite,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("No access"),
|
label: t("Remove"),
|
||||||
value: EmptySelectValue,
|
value: EmptySelectValue,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -69,7 +67,7 @@ const MemberListItem = ({
|
|||||||
const MaybeLink = membership?.source ? StyledLink : React.Fragment;
|
const MaybeLink = membership?.source ? StyledLink : React.Fragment;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledListItem
|
<ListItem
|
||||||
title={user.name}
|
title={user.name}
|
||||||
image={
|
image={
|
||||||
<Avatar model={user} size={AvatarSize.Medium} showBorder={false} />
|
<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)`
|
const StyledLink = styled(Link)`
|
||||||
color: ${s("textTertiary")};
|
color: ${s("textTertiary")};
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default observer(MemberListItem);
|
export default observer(DocumentMemberListItem);
|
||||||
@@ -12,11 +12,11 @@ import Text from "~/components/Text";
|
|||||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||||
import useRequest from "~/hooks/useRequest";
|
import useRequest from "~/hooks/useRequest";
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
import Avatar from "../Avatar";
|
import Avatar from "../../Avatar";
|
||||||
import { AvatarSize } from "../Avatar/Avatar";
|
import { AvatarSize } from "../../Avatar/Avatar";
|
||||||
import CollectionIcon from "../Icons/CollectionIcon";
|
import CollectionIcon from "../../Icons/CollectionIcon";
|
||||||
import Tooltip from "../Tooltip";
|
import Tooltip from "../../Tooltip";
|
||||||
import { StyledListItem } from "./MemberListItem";
|
import { ListItem } from "../components/ListItem";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
/** The document being shared. */
|
/** The document being shared. */
|
||||||
@@ -36,7 +36,7 @@ export const OtherAccess = observer(({ document, children }: Props) => {
|
|||||||
{collection ? (
|
{collection ? (
|
||||||
<>
|
<>
|
||||||
{collection.permission ? (
|
{collection.permission ? (
|
||||||
<StyledListItem
|
<ListItem
|
||||||
image={
|
image={
|
||||||
<Squircle color={theme.accent} size={AvatarSize.Medium}>
|
<Squircle color={theme.accent} size={AvatarSize.Medium}>
|
||||||
<UserIcon color={theme.accentText} size={16} />
|
<UserIcon color={theme.accentText} size={16} />
|
||||||
@@ -53,7 +53,7 @@ export const OtherAccess = observer(({ document, children }: Props) => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : usersInCollection ? (
|
) : usersInCollection ? (
|
||||||
<StyledListItem
|
<ListItem
|
||||||
image={
|
image={
|
||||||
<Squircle color={collection.color} size={AvatarSize.Medium}>
|
<Squircle color={collection.color} size={AvatarSize.Medium}>
|
||||||
<CollectionIcon
|
<CollectionIcon
|
||||||
@@ -68,7 +68,7 @@ export const OtherAccess = observer(({ document, children }: Props) => {
|
|||||||
actions={<AccessTooltip>{t("Can view")}</AccessTooltip>}
|
actions={<AccessTooltip>{t("Can view")}</AccessTooltip>}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<StyledListItem
|
<ListItem
|
||||||
image={<Avatar model={user} showBorder={false} />}
|
image={<Avatar model={user} showBorder={false} />}
|
||||||
title={user.name}
|
title={user.name}
|
||||||
subtitle={t("You have full access")}
|
subtitle={t("You have full access")}
|
||||||
@@ -79,7 +79,7 @@ export const OtherAccess = observer(({ document, children }: Props) => {
|
|||||||
</>
|
</>
|
||||||
) : document.isDraft ? (
|
) : document.isDraft ? (
|
||||||
<>
|
<>
|
||||||
<StyledListItem
|
<ListItem
|
||||||
image={<Avatar model={document.createdBy} showBorder={false} />}
|
image={<Avatar model={document.createdBy} showBorder={false} />}
|
||||||
title={document.createdBy?.name}
|
title={document.createdBy?.name}
|
||||||
actions={
|
actions={
|
||||||
@@ -93,7 +93,7 @@ export const OtherAccess = observer(({ document, children }: Props) => {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{children}
|
{children}
|
||||||
<StyledListItem
|
<ListItem
|
||||||
image={
|
image={
|
||||||
<Squircle color={theme.accent} size={AvatarSize.Medium}>
|
<Squircle color={theme.accent} size={AvatarSize.Medium}>
|
||||||
<MoreIcon color={theme.accentText} size={16} />
|
<MoreIcon color={theme.accentText} size={16} />
|
||||||
@@ -18,13 +18,13 @@ import Switch from "~/components/Switch";
|
|||||||
import env from "~/env";
|
import env from "~/env";
|
||||||
import usePolicy from "~/hooks/usePolicy";
|
import usePolicy from "~/hooks/usePolicy";
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
import { AvatarSize } from "../Avatar/Avatar";
|
import { AvatarSize } from "../../Avatar/Avatar";
|
||||||
import CopyToClipboard from "../CopyToClipboard";
|
import CopyToClipboard from "../../CopyToClipboard";
|
||||||
import NudeButton from "../NudeButton";
|
import NudeButton from "../../NudeButton";
|
||||||
import { ResizingHeightContainer } from "../ResizingHeightContainer";
|
import { ResizingHeightContainer } from "../../ResizingHeightContainer";
|
||||||
import Text from "../Text";
|
import Text from "../../Text";
|
||||||
import Tooltip from "../Tooltip";
|
import Tooltip from "../../Tooltip";
|
||||||
import { StyledListItem } from "./MemberListItem";
|
import { ListItem } from "../components/ListItem";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
/** The document to share. */
|
/** The document to share. */
|
||||||
@@ -122,7 +122,7 @@ function PublicAccess({ document, share, sharedParent }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Wrapper>
|
<Wrapper>
|
||||||
<StyledListItem
|
<ListItem
|
||||||
title={t("Web")}
|
title={t("Web")}
|
||||||
subtitle={
|
subtitle={
|
||||||
<>
|
<>
|
||||||
@@ -1,37 +1,34 @@
|
|||||||
import { isEmail } from "class-validator";
|
import { isEmail } from "class-validator";
|
||||||
import { AnimatePresence, m } from "framer-motion";
|
import { m } from "framer-motion";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { BackIcon, LinkIcon } from "outline-icons";
|
import { BackIcon, LinkIcon } from "outline-icons";
|
||||||
import { darken } from "polished";
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import styled from "styled-components";
|
|
||||||
import { s } from "@shared/styles";
|
|
||||||
import { DocumentPermission, UserRole } from "@shared/types";
|
import { DocumentPermission, UserRole } from "@shared/types";
|
||||||
import Document from "~/models/Document";
|
import Document from "~/models/Document";
|
||||||
import Share from "~/models/Share";
|
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 CopyToClipboard from "~/components/CopyToClipboard";
|
||||||
import Flex from "~/components/Flex";
|
import NudeButton from "~/components/NudeButton";
|
||||||
|
import Tooltip from "~/components/Tooltip";
|
||||||
import { createAction } from "~/actions";
|
import { createAction } from "~/actions";
|
||||||
import { UserSection } from "~/actions/sections";
|
import { UserSection } from "~/actions/sections";
|
||||||
import useActionContext from "~/hooks/useActionContext";
|
import useActionContext from "~/hooks/useActionContext";
|
||||||
import useBoolean from "~/hooks/useBoolean";
|
import useBoolean from "~/hooks/useBoolean";
|
||||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||||
import useKeyDown from "~/hooks/useKeyDown";
|
import useKeyDown from "~/hooks/useKeyDown";
|
||||||
import useMobile from "~/hooks/useMobile";
|
|
||||||
import usePolicy from "~/hooks/usePolicy";
|
import usePolicy from "~/hooks/usePolicy";
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
import { hover } from "~/styles";
|
|
||||||
import { documentPath, urlify } from "~/utils/routeHelpers";
|
import { documentPath, urlify } from "~/utils/routeHelpers";
|
||||||
import ButtonSmall from "../ButtonSmall";
|
import { Separator, Wrapper, presence } from "../components";
|
||||||
import Input, { NativeInput } from "../Input";
|
import { SearchInput } from "../components/SearchInput";
|
||||||
import NudeButton from "../NudeButton";
|
import { Suggestions } from "../components/Suggestions";
|
||||||
import Tooltip from "../Tooltip";
|
|
||||||
import DocumentMembersList from "./DocumentMemberList";
|
import DocumentMembersList from "./DocumentMemberList";
|
||||||
import { OtherAccess } from "./OtherAccess";
|
import { OtherAccess } from "./OtherAccess";
|
||||||
import PublicAccess from "./PublicAccess";
|
import PublicAccess from "./PublicAccess";
|
||||||
import { UserSuggestions } from "./UserSuggestions";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
/** The document to share. */
|
/** The document to share. */
|
||||||
@@ -46,29 +43,6 @@ type Props = {
|
|||||||
visible: boolean;
|
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({
|
function SharePopover({
|
||||||
document,
|
document,
|
||||||
share,
|
share,
|
||||||
@@ -79,13 +53,13 @@ function SharePopover({
|
|||||||
const team = useCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const can = usePolicy(document);
|
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 { users, userMemberships } = useStores();
|
||||||
const isMobile = useMobile();
|
|
||||||
const [query, setQuery] = React.useState("");
|
const [query, setQuery] = React.useState("");
|
||||||
const [picker, showPicker, hidePicker] = useBoolean();
|
const [picker, showPicker, hidePicker] = useBoolean();
|
||||||
const timeout = React.useRef<ReturnType<typeof setTimeout>>();
|
const timeout = React.useRef<ReturnType<typeof setTimeout>>();
|
||||||
const linkButtonRef = React.useRef<HTMLButtonElement>(null);
|
|
||||||
const [invitedInSession, setInvitedInSession] = React.useState<string[]>([]);
|
const [invitedInSession, setInvitedInSession] = React.useState<string[]>([]);
|
||||||
const [pendingIds, setPendingIds] = React.useState<string[]>([]);
|
const [pendingIds, setPendingIds] = React.useState<string[]>([]);
|
||||||
const collectionSharingDisabled = document.collection?.sharing === false;
|
const collectionSharingDisabled = document.collection?.sharing === false;
|
||||||
@@ -111,6 +85,7 @@ function SharePopover({
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
void document.share();
|
void document.share();
|
||||||
|
setHasRendered(true);
|
||||||
}
|
}
|
||||||
}, [document, hidePicker, visible]);
|
}, [document, hidePicker, visible]);
|
||||||
|
|
||||||
@@ -143,8 +118,6 @@ function SharePopover({
|
|||||||
};
|
};
|
||||||
}, [onRequestClose, t]);
|
}, [onRequestClose, t]);
|
||||||
|
|
||||||
const context = useActionContext();
|
|
||||||
|
|
||||||
const inviteAction = React.useMemo(
|
const inviteAction = React.useMemo(
|
||||||
() =>
|
() =>
|
||||||
createAction({
|
createAction({
|
||||||
@@ -188,13 +161,17 @@ function SharePopover({
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (usersInvited.length === 1) {
|
if (usersInvited.length === 1) {
|
||||||
|
const user = usersInvited[0];
|
||||||
toast.message(
|
toast.message(
|
||||||
t("{{ userName }} was invited to the document", {
|
t("{{ userName }} was invited to the document", {
|
||||||
userName: usersInvited[0].name,
|
userName: user.name,
|
||||||
})
|
}),
|
||||||
|
{
|
||||||
|
icon: <Avatar model={user} size={AvatarSize.Toast} />,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
toast.message(
|
toast.success(
|
||||||
t("{{ count }} people invited to the document", {
|
t("{{ count }} people invited to the document", {
|
||||||
count: pendingIds.length,
|
count: pendingIds.length,
|
||||||
})
|
})
|
||||||
@@ -225,13 +202,6 @@ function SharePopover({
|
|||||||
[showPicker, setQuery]
|
[showPicker, setQuery]
|
||||||
);
|
);
|
||||||
|
|
||||||
const focusInput = React.useCallback(() => {
|
|
||||||
if (!picker) {
|
|
||||||
inputRef.current?.focus();
|
|
||||||
showPicker();
|
|
||||||
}
|
|
||||||
}, [picker, showPicker]);
|
|
||||||
|
|
||||||
const handleAddPendingId = React.useCallback(
|
const handleAddPendingId = React.useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
setPendingIds((prev) => [...prev, id]);
|
setPendingIds((prev) => [...prev, id]);
|
||||||
@@ -246,10 +216,23 @@ function SharePopover({
|
|||||||
[setPendingIds]
|
[setPendingIds]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!hasRendered) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const backButton = (
|
const backButton = (
|
||||||
<>
|
<>
|
||||||
{picker && (
|
{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 />
|
<BackIcon />
|
||||||
</NudeButton>
|
</NudeButton>
|
||||||
)}
|
)}
|
||||||
@@ -282,44 +265,19 @@ function SharePopover({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Wrapper>
|
<Wrapper>
|
||||||
{can.manageUsers &&
|
{can.manageUsers && (
|
||||||
(isMobile ? (
|
<SearchInput
|
||||||
<Flex align="center" style={{ marginBottom: 12 }} auto>
|
onChange={handleQuery}
|
||||||
{backButton}
|
onClick={showPicker}
|
||||||
<Input
|
query={query}
|
||||||
key="input"
|
back={backButton}
|
||||||
placeholder={`${t("Invite")}…`}
|
action={rightButton}
|
||||||
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>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{picker && (
|
{picker && (
|
||||||
<div>
|
<div>
|
||||||
<UserSuggestions
|
<Suggestions
|
||||||
document={document}
|
document={document}
|
||||||
query={query}
|
query={query}
|
||||||
pendingIds={pendingIds}
|
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);
|
export default observer(SharePopover);
|
||||||
21
app/components/Sharing/components/ListItem.tsx
Normal file
21
app/components/Sharing/components/ListItem.tsx
Normal 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;
|
||||||
|
}
|
||||||
|
`;
|
||||||
63
app/components/Sharing/components/SearchInput.tsx
Normal file
63
app/components/Sharing/components/SearchInput.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,21 +1,24 @@
|
|||||||
import { isEmail } from "class-validator";
|
import { isEmail } from "class-validator";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { CheckmarkIcon, CloseIcon } from "outline-icons";
|
import { CheckmarkIcon, CloseIcon, GroupIcon } from "outline-icons";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { s } from "@shared/styles";
|
||||||
import { stringToColor } from "@shared/utils/color";
|
import { stringToColor } from "@shared/utils/color";
|
||||||
|
import Collection from "~/models/Collection";
|
||||||
import Document from "~/models/Document";
|
import Document from "~/models/Document";
|
||||||
|
import Group from "~/models/Group";
|
||||||
import User from "~/models/User";
|
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 useCurrentUser from "~/hooks/useCurrentUser";
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
import useThrottledCallback from "~/hooks/useThrottledCallback";
|
import useThrottledCallback from "~/hooks/useThrottledCallback";
|
||||||
import { hover } from "~/styles";
|
import { hover } from "~/styles";
|
||||||
import Avatar from "../Avatar";
|
import { InviteIcon, ListItem } from "./ListItem";
|
||||||
import { AvatarSize, IAvatar } from "../Avatar/Avatar";
|
|
||||||
import Empty from "../Empty";
|
|
||||||
import { InviteIcon, StyledListItem } from "./MemberListItem";
|
|
||||||
|
|
||||||
type Suggestion = IAvatar & {
|
type Suggestion = IAvatar & {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -23,7 +26,9 @@ type Suggestion = IAvatar & {
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
/** The document being shared. */
|
/** The document being shared. */
|
||||||
document: Document;
|
document?: Document;
|
||||||
|
/** The collection being shared. */
|
||||||
|
collection?: Collection;
|
||||||
/** The search query to filter users by. */
|
/** The search query to filter users by. */
|
||||||
query: string;
|
query: string;
|
||||||
/** A list of pending user ids that have not yet been invited. */
|
/** A list of pending user ids that have not yet been invited. */
|
||||||
@@ -32,18 +37,32 @@ type Props = {
|
|||||||
addPendingId: (id: string) => void;
|
addPendingId: (id: string) => void;
|
||||||
/** Callback to remove a user from the pending list. */
|
/** Callback to remove a user from the pending list. */
|
||||||
removePendingId: (id: string) => void;
|
removePendingId: (id: string) => void;
|
||||||
|
/** Show group suggestions. */
|
||||||
|
showGroups?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const UserSuggestions = observer(
|
export const Suggestions = observer(
|
||||||
({ document, query, pendingIds, addPendingId, removePendingId }: Props) => {
|
({
|
||||||
const { users } = useStores();
|
document,
|
||||||
|
collection,
|
||||||
|
query,
|
||||||
|
pendingIds,
|
||||||
|
addPendingId,
|
||||||
|
removePendingId,
|
||||||
|
showGroups,
|
||||||
|
}: Props) => {
|
||||||
|
const { users, groups } = useStores();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const user = useCurrentUser();
|
const user = useCurrentUser();
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
const fetchUsersByQuery = useThrottledCallback(
|
const fetchUsersByQuery = useThrottledCallback((params) => {
|
||||||
(params) => users.fetchPage({ query: params.query }),
|
void users.fetchPage({ query: params.query });
|
||||||
250
|
|
||||||
);
|
if (showGroups) {
|
||||||
|
void groups.fetchPage({ query: params.query });
|
||||||
|
}
|
||||||
|
}, 250);
|
||||||
|
|
||||||
const getSuggestionForEmail = React.useCallback(
|
const getSuggestionForEmail = React.useCallback(
|
||||||
(email: string) => ({
|
(email: string) => ({
|
||||||
@@ -58,21 +77,30 @@ export const UserSuggestions = observer(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const suggestions = React.useMemo(() => {
|
const suggestions = React.useMemo(() => {
|
||||||
const filtered: Suggestion[] = users
|
const filtered: Suggestion[] = (
|
||||||
.notInDocument(document.id, query)
|
document
|
||||||
.filter((u) => u.id !== user.id && !u.isSuspended);
|
? users.notInDocument(document.id, query)
|
||||||
|
: collection
|
||||||
|
? users.notInCollection(collection.id, query)
|
||||||
|
: users.orderedData
|
||||||
|
).filter((u) => u.id !== user.id && !u.isSuspended);
|
||||||
|
|
||||||
if (isEmail(query)) {
|
if (isEmail(query)) {
|
||||||
filtered.push(getSuggestionForEmail(query));
|
filtered.push(getSuggestionForEmail(query));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (collection?.id) {
|
||||||
|
return [...groups.notInCollection(collection.id, query), ...filtered];
|
||||||
|
}
|
||||||
|
|
||||||
return filtered;
|
return filtered;
|
||||||
}, [
|
}, [
|
||||||
getSuggestionForEmail,
|
getSuggestionForEmail,
|
||||||
users,
|
users,
|
||||||
users.orderedData,
|
users.orderedData,
|
||||||
document.id,
|
document?.id,
|
||||||
document.members,
|
document?.members,
|
||||||
|
collection?.id,
|
||||||
user.id,
|
user.id,
|
||||||
query,
|
query,
|
||||||
t,
|
t,
|
||||||
@@ -82,19 +110,32 @@ export const UserSuggestions = observer(
|
|||||||
() =>
|
() =>
|
||||||
pendingIds
|
pendingIds
|
||||||
.map((id) =>
|
.map((id) =>
|
||||||
isEmail(id) ? getSuggestionForEmail(id) : users.get(id)
|
isEmail(id)
|
||||||
|
? getSuggestionForEmail(id)
|
||||||
|
: users.get(id) ?? groups.get(id)
|
||||||
)
|
)
|
||||||
.filter(Boolean) as User[],
|
.filter(Boolean) as User[],
|
||||||
[users, getSuggestionForEmail, pendingIds]
|
[users, getSuggestionForEmail, pendingIds]
|
||||||
);
|
);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (query) {
|
void fetchUsersByQuery(query);
|
||||||
void fetchUsersByQuery(query);
|
|
||||||
}
|
|
||||||
}, [query, fetchUsersByQuery]);
|
}, [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 {
|
return {
|
||||||
title: suggestion.name,
|
title: suggestion.name,
|
||||||
subtitle: suggestion.email
|
subtitle: suggestion.email
|
||||||
@@ -135,7 +176,7 @@ export const UserSuggestions = observer(
|
|||||||
{pending.length > 0 &&
|
{pending.length > 0 &&
|
||||||
(suggestionsWithPending.length > 0 || isEmpty) && <Separator />}
|
(suggestionsWithPending.length > 0 || isEmpty) && <Separator />}
|
||||||
{suggestionsWithPending.map((suggestion) => (
|
{suggestionsWithPending.map((suggestion) => (
|
||||||
<StyledListItem
|
<ListItem
|
||||||
{...getListItemProps(suggestion as User)}
|
{...getListItemProps(suggestion as User)}
|
||||||
key={suggestion.id}
|
key={suggestion.id}
|
||||||
onClick={() => addPendingId(suggestion.id)}
|
onClick={() => addPendingId(suggestion.id)}
|
||||||
@@ -156,7 +197,7 @@ const RemoveIcon = styled(CloseIcon)`
|
|||||||
display: none;
|
display: none;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const PendingListItem = styled(StyledListItem)`
|
const PendingListItem = styled(ListItem)`
|
||||||
&: ${hover} {
|
&: ${hover} {
|
||||||
${InvitedIcon} {
|
${InvitedIcon} {
|
||||||
display: none;
|
display: none;
|
||||||
67
app/components/Sharing/components/index.tsx
Normal file
67
app/components/Sharing/components/index.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import { observable } from "mobx";
|
import { observable } from "mobx";
|
||||||
import { CollectionPermission } from "@shared/types";
|
import { CollectionPermission } from "@shared/types";
|
||||||
|
import Collection from "./Collection";
|
||||||
|
import User from "./User";
|
||||||
import Model from "./base/Model";
|
import Model from "./base/Model";
|
||||||
|
import Relation from "./decorators/Relation";
|
||||||
|
|
||||||
class Membership extends Model {
|
class Membership extends Model {
|
||||||
static modelName = "Membership";
|
static modelName = "Membership";
|
||||||
@@ -9,8 +12,14 @@ class Membership extends Model {
|
|||||||
|
|
||||||
userId: string;
|
userId: string;
|
||||||
|
|
||||||
|
@Relation(() => User, { onDelete: "cascade" })
|
||||||
|
user: User;
|
||||||
|
|
||||||
collectionId: string;
|
collectionId: string;
|
||||||
|
|
||||||
|
@Relation(() => Collection, { onDelete: "cascade" })
|
||||||
|
collection: Collection;
|
||||||
|
|
||||||
@observable
|
@observable
|
||||||
permission: CollectionPermission;
|
permission: CollectionPermission;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { observable } from "mobx";
|
import { observable } from "mobx";
|
||||||
|
import Collection from "./Collection";
|
||||||
import Document from "./Document";
|
import Document from "./Document";
|
||||||
import User from "./User";
|
import User from "./User";
|
||||||
import Model from "./base/Model";
|
import Model from "./base/Model";
|
||||||
@@ -25,6 +26,15 @@ class Share extends Model {
|
|||||||
@Relation(() => Document, { onDelete: "cascade" })
|
@Relation(() => Document, { onDelete: "cascade" })
|
||||||
document: Document;
|
document: Document;
|
||||||
|
|
||||||
|
/** The collection ID that is shared. */
|
||||||
|
@Field
|
||||||
|
@observable
|
||||||
|
collectionId: string;
|
||||||
|
|
||||||
|
/** The collection that is shared. */
|
||||||
|
@Relation(() => Collection, { onDelete: "cascade" })
|
||||||
|
collection: Collection;
|
||||||
|
|
||||||
@Field
|
@Field
|
||||||
@observable
|
@observable
|
||||||
urlId: string;
|
urlId: string;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import Text from "~/components/Text";
|
|||||||
import { editCollectionPermissions } from "~/actions/definitions/collections";
|
import { editCollectionPermissions } from "~/actions/definitions/collections";
|
||||||
import useActionContext from "~/hooks/useActionContext";
|
import useActionContext from "~/hooks/useActionContext";
|
||||||
import usePolicy from "~/hooks/usePolicy";
|
import usePolicy from "~/hooks/usePolicy";
|
||||||
|
import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
|
||||||
import { newDocumentPath } from "~/utils/routeHelpers";
|
import { newDocumentPath } from "~/utils/routeHelpers";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -48,14 +49,16 @@ function EmptyCollection({ collection }: Props) {
|
|||||||
{t("Create a document")}
|
{t("Create a document")}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Button
|
{FeatureFlags.isEnabled(Feature.newCollectionSharing) ? null : (
|
||||||
action={editCollectionPermissions}
|
<Button
|
||||||
context={context}
|
action={editCollectionPermissions}
|
||||||
hideOnActionDisabled
|
context={context}
|
||||||
neutral
|
hideOnActionDisabled
|
||||||
>
|
neutral
|
||||||
{t("Manage permissions")}…
|
>
|
||||||
</Button>
|
{t("Manage permissions")}…
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Empty>
|
</Empty>
|
||||||
)}
|
)}
|
||||||
</Centered>
|
</Centered>
|
||||||
@@ -2,10 +2,8 @@ import sortBy from "lodash/sortBy";
|
|||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import styled from "styled-components";
|
|
||||||
import { PAGINATION_SYMBOL } from "~/stores/base/Store";
|
import { PAGINATION_SYMBOL } from "~/stores/base/Store";
|
||||||
import Collection from "~/models/Collection";
|
import Collection from "~/models/Collection";
|
||||||
import User from "~/models/User";
|
|
||||||
import Avatar from "~/components/Avatar";
|
import Avatar from "~/components/Avatar";
|
||||||
import Facepile from "~/components/Facepile";
|
import Facepile from "~/components/Facepile";
|
||||||
import Fade from "~/components/Fade";
|
import Fade from "~/components/Fade";
|
||||||
@@ -14,6 +12,7 @@ import { editCollectionPermissions } from "~/actions/definitions/collections";
|
|||||||
import useActionContext from "~/hooks/useActionContext";
|
import useActionContext from "~/hooks/useActionContext";
|
||||||
import useMobile from "~/hooks/useMobile";
|
import useMobile from "~/hooks/useMobile";
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
|
import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
collection: Collection;
|
collection: Collection;
|
||||||
@@ -72,7 +71,11 @@ const MembershipPreview = ({ collection, limit = 8 }: Props) => {
|
|||||||
return (
|
return (
|
||||||
<NudeButton
|
<NudeButton
|
||||||
context={context}
|
context={context}
|
||||||
action={editCollectionPermissions}
|
action={
|
||||||
|
FeatureFlags.isEnabled(Feature.newCollectionSharing)
|
||||||
|
? undefined
|
||||||
|
: editCollectionPermissions
|
||||||
|
}
|
||||||
tooltip={{
|
tooltip={{
|
||||||
content:
|
content:
|
||||||
usersCount > 0
|
usersCount > 0
|
||||||
@@ -104,16 +107,11 @@ const MembershipPreview = ({ collection, limit = 8 }: Props) => {
|
|||||||
users={sortBy(collectionUsers, "lastActiveAt")}
|
users={sortBy(collectionUsers, "lastActiveAt")}
|
||||||
overflow={overflow}
|
overflow={overflow}
|
||||||
limit={limit}
|
limit={limit}
|
||||||
renderAvatar={(user) => <StyledAvatar model={user} size={32} />}
|
renderAvatar={(user) => <Avatar model={user} size={32} />}
|
||||||
/>
|
/>
|
||||||
</Fade>
|
</Fade>
|
||||||
</NudeButton>
|
</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);
|
export default observer(MembershipPreview);
|
||||||
60
app/scenes/Collection/components/ShareButton.tsx
Normal file
60
app/scenes/Collection/components/ShareButton.tsx
Normal 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);
|
||||||
@@ -15,6 +15,7 @@ import breakpoint from "styled-components-breakpoint";
|
|||||||
import { s } from "@shared/styles";
|
import { s } from "@shared/styles";
|
||||||
import Collection from "~/models/Collection";
|
import Collection from "~/models/Collection";
|
||||||
import Search from "~/scenes/Search";
|
import Search from "~/scenes/Search";
|
||||||
|
import { Action } from "~/components/Actions";
|
||||||
import Badge from "~/components/Badge";
|
import Badge from "~/components/Badge";
|
||||||
import CenteredContent from "~/components/CenteredContent";
|
import CenteredContent from "~/components/CenteredContent";
|
||||||
import CollectionDescription from "~/components/CollectionDescription";
|
import CollectionDescription from "~/components/CollectionDescription";
|
||||||
@@ -34,11 +35,13 @@ import useCommandBarActions from "~/hooks/useCommandBarActions";
|
|||||||
import useLastVisitedPath from "~/hooks/useLastVisitedPath";
|
import useLastVisitedPath from "~/hooks/useLastVisitedPath";
|
||||||
import usePolicy from "~/hooks/usePolicy";
|
import usePolicy from "~/hooks/usePolicy";
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
|
import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
|
||||||
import { collectionPath, updateCollectionPath } from "~/utils/routeHelpers";
|
import { collectionPath, updateCollectionPath } from "~/utils/routeHelpers";
|
||||||
import Actions from "./Collection/Actions";
|
import Actions from "./components/Actions";
|
||||||
import DropToImport from "./Collection/DropToImport";
|
import DropToImport from "./components/DropToImport";
|
||||||
import Empty from "./Collection/Empty";
|
import Empty from "./components/Empty";
|
||||||
import MembershipPreview from "./Collection/MembershipPreview";
|
import MembershipPreview from "./components/MembershipPreview";
|
||||||
|
import ShareButton from "./components/ShareButton";
|
||||||
|
|
||||||
function CollectionScene() {
|
function CollectionScene() {
|
||||||
const params = useParams<{ id?: string }>();
|
const params = useParams<{ id?: string }>();
|
||||||
@@ -142,6 +145,10 @@ function CollectionScene() {
|
|||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<MembershipPreview collection={collection} />
|
<MembershipPreview collection={collection} />
|
||||||
|
<Action>
|
||||||
|
{FeatureFlags.isEnabled(Feature.newCollectionSharing) &&
|
||||||
|
can.update && <ShareButton collection={collection} />}
|
||||||
|
</Action>
|
||||||
<Actions collection={collection} />
|
<Actions collection={collection} />
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@@ -159,16 +166,17 @@ function CollectionScene() {
|
|||||||
<HeadingWithIcon>
|
<HeadingWithIcon>
|
||||||
<HeadingIcon collection={collection} size={40} expanded />
|
<HeadingIcon collection={collection} size={40} expanded />
|
||||||
{collection.name}
|
{collection.name}
|
||||||
{collection.isPrivate && (
|
{collection.isPrivate &&
|
||||||
<Tooltip
|
!FeatureFlags.isEnabled(Feature.newCollectionSharing) && (
|
||||||
content={t(
|
<Tooltip
|
||||||
"This collection is only visible to those given access"
|
content={t(
|
||||||
)}
|
"This collection is only visible to those given access"
|
||||||
placement="bottom"
|
)}
|
||||||
>
|
placement="bottom"
|
||||||
<Badge>{t("Private")}</Badge>
|
>
|
||||||
</Tooltip>
|
<Badge>{t("Private")}</Badge>
|
||||||
)}
|
</Tooltip>
|
||||||
|
)}
|
||||||
</HeadingWithIcon>
|
</HeadingWithIcon>
|
||||||
|
|
||||||
<PinnedDocuments
|
<PinnedDocuments
|
||||||
@@ -6,11 +6,12 @@ import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
|
|||||||
import Document from "~/models/Document";
|
import Document from "~/models/Document";
|
||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
import Popover from "~/components/Popover";
|
import Popover from "~/components/Popover";
|
||||||
import SharePopover from "~/components/Sharing";
|
import SharePopover from "~/components/Sharing/Document";
|
||||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
/** Document being shared */
|
||||||
document: Document;
|
document: Document;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -32,15 +33,13 @@ function ShareButton({ document }: Props) {
|
|||||||
unstable_fixed: true,
|
unstable_fixed: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const icon = isPubliclyShared ? <GlobeIcon /> : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PopoverDisclosure {...popover}>
|
<PopoverDisclosure {...popover}>
|
||||||
{(props) => (
|
{(props) => (
|
||||||
<Button
|
<Button icon={icon} neutral {...props}>
|
||||||
icon={isPubliclyShared ? <GlobeIcon /> : undefined}
|
|
||||||
neutral
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{t("Share")} {domain && <>· {domain}</>}
|
{t("Share")} {domain && <>· {domain}</>}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -76,4 +76,7 @@ export default class CollectionGroupMembershipsStore extends Store<CollectionGro
|
|||||||
groupId,
|
groupId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inCollection = (collectionId: string) =>
|
||||||
|
this.orderedData.filter((cgm) => cgm.collectionId === collectionId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,4 +82,9 @@ export default class MembershipsStore extends Store<Membership> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
inCollection = (collectionId: string) =>
|
||||||
|
this.orderedData.filter(
|
||||||
|
(membership) => membership.collectionId === collectionId
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,6 +104,9 @@ export default class SharesStore extends Store<Share> {
|
|||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
getByCollectionId = (collectionId: string): Share | null | undefined =>
|
||||||
|
find(this.orderedData, (share) => share.collectionId === collectionId);
|
||||||
|
|
||||||
getByDocumentId = (documentId: string): Share | null | undefined =>
|
getByDocumentId = (documentId: string): Share | null | undefined =>
|
||||||
find(this.orderedData, (share) => share.documentId === documentId);
|
find(this.orderedData, (share) => share.documentId === documentId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -221,6 +221,7 @@ export const EmptySelectValue = "__empty__";
|
|||||||
export type Permission = {
|
export type Permission = {
|
||||||
label: string;
|
label: string;
|
||||||
value: CollectionPermission | DocumentPermission | typeof EmptySelectValue;
|
value: CollectionPermission | DocumentPermission | typeof EmptySelectValue;
|
||||||
|
divider?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
// TODO: Can we make this type driven by the @Field decorator
|
// TODO: Can we make this type driven by the @Field decorator
|
||||||
|
|||||||
43
app/utils/FeatureFlags.ts
Normal file
43
app/utils/FeatureFlags.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -264,6 +264,20 @@
|
|||||||
"Documents": "Documents",
|
"Documents": "Documents",
|
||||||
"Results": "Results",
|
"Results": "Results",
|
||||||
"No results for {{query}}": "No results for {{query}}",
|
"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",
|
"{{ userName }} was removed from the document": "{{ userName }} was removed from the document",
|
||||||
"Could not remove user": "Could not remove user",
|
"Could not remove user": "Could not remove user",
|
||||||
"Permissions for {{ userName }} updated": "Permissions for {{ userName }} updated",
|
"Permissions for {{ userName }} updated": "Permissions for {{ userName }} updated",
|
||||||
@@ -271,11 +285,7 @@
|
|||||||
"Has access through <2>parent</2>": "Has access through <2>parent</2>",
|
"Has access through <2>parent</2>": "Has access through <2>parent</2>",
|
||||||
"Suspended": "Suspended",
|
"Suspended": "Suspended",
|
||||||
"Invited": "Invited",
|
"Invited": "Invited",
|
||||||
"Viewer": "Viewer",
|
|
||||||
"Editor": "Editor",
|
|
||||||
"Leave": "Leave",
|
"Leave": "Leave",
|
||||||
"All members": "All members",
|
|
||||||
"Everyone in the workspace": "Everyone in the workspace",
|
|
||||||
"Can view": "Can view",
|
"Can view": "Can view",
|
||||||
"Everyone in the collection": "Everyone in the collection",
|
"Everyone in the collection": "Everyone in the collection",
|
||||||
"You have full access": "You have full access",
|
"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",
|
"Allow anyone with the link to access": "Allow anyone with the link to access",
|
||||||
"Publish to internet": "Publish to internet",
|
"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",
|
"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",
|
"{{ 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": "{{ count }} people invited to the document",
|
||||||
"{{ count }} people invited to the document_plural": "{{ 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",
|
"Logo": "Logo",
|
||||||
"Move document": "Move document",
|
"Move document": "Move document",
|
||||||
"New doc": "New doc",
|
"New doc": "New doc",
|
||||||
@@ -485,12 +493,6 @@
|
|||||||
"API token created": "API token created",
|
"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\".",
|
"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.",
|
"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",
|
|
||||||
"A–Z": "A–Z",
|
|
||||||
"Collection menu": "Collection menu",
|
"Collection menu": "Collection menu",
|
||||||
"Drop documents to import": "Drop documents to import",
|
"Drop documents to import": "Drop documents to import",
|
||||||
"<em>{{ collectionName }}</em> doesn’t contain any\n documents yet.": "<em>{{ collectionName }}</em> doesn’t contain any\n documents yet.",
|
"<em>{{ collectionName }}</em> doesn’t contain any\n documents yet.": "<em>{{ collectionName }}</em> doesn’t contain any\n documents yet.",
|
||||||
@@ -505,6 +507,12 @@
|
|||||||
"{{ usersCount }} users with access_plural": "{{ usersCount }} users with access",
|
"{{ usersCount }} users with access_plural": "{{ usersCount }} users with access",
|
||||||
"{{ groupsCount }} groups with access": "{{ groupsCount }} group with access",
|
"{{ groupsCount }} groups with access": "{{ groupsCount }} group with access",
|
||||||
"{{ groupsCount }} groups with access_plural": "{{ groupsCount }} groups 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",
|
||||||
|
"A–Z": "A–Z",
|
||||||
"{{ groupName }} was added to the collection": "{{ groupName }} was added to the collection",
|
"{{ groupName }} was added to the collection": "{{ groupName }} was added to the collection",
|
||||||
"Could not add user": "Could not add user",
|
"Could not add user": "Could not add user",
|
||||||
"Can’t find the group you’re looking for?": "Can’t find the group you’re looking for?",
|
"Can’t find the group you’re looking for?": "Can’t find the group you’re looking for?",
|
||||||
@@ -513,8 +521,6 @@
|
|||||||
"Search groups": "Search groups",
|
"Search groups": "Search groups",
|
||||||
"No groups matching your search": "No groups matching your search",
|
"No groups matching your search": "No groups matching your search",
|
||||||
"No groups left to add": "No groups left to add",
|
"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 who’s not on the team yet?": "Need to add someone who’s not on the team yet?",
|
"Need to add someone who’s not on the team yet?": "Need to add someone who’s not on the team yet?",
|
||||||
"Invite people to {{ teamName }}": "Invite people to {{ teamName }}",
|
"Invite people to {{ teamName }}": "Invite people to {{ teamName }}",
|
||||||
"Ask an admin to invite them first": "Ask an admin to invite them first",
|
"Ask an admin to invite them first": "Ask an admin to invite them first",
|
||||||
@@ -522,7 +528,6 @@
|
|||||||
"Search people": "Search people",
|
"Search people": "Search people",
|
||||||
"No people matching your search": "No people matching your search",
|
"No people matching your search": "No people matching your search",
|
||||||
"No people left to add": "No people left to add",
|
"No people left to add": "No people left to add",
|
||||||
"Admin": "Admin",
|
|
||||||
"Active <1></1> ago": "Active <1></1> ago",
|
"Active <1></1> ago": "Active <1></1> ago",
|
||||||
"Never signed in": "Never signed in",
|
"Never signed in": "Never signed in",
|
||||||
"{{ userName }} was removed from the collection": "{{ userName }} was removed from the collection",
|
"{{ userName }} was removed from the collection": "{{ userName }} was removed from the collection",
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ export const buildDarkTheme = (input: Partial<Colors>): DefaultTheme => {
|
|||||||
textDiffInsertedBackground: "rgba(63,185,80,0.3)",
|
textDiffInsertedBackground: "rgba(63,185,80,0.3)",
|
||||||
textDiffDeleted: darken(0.1, colors.almostWhite),
|
textDiffDeleted: darken(0.1, colors.almostWhite),
|
||||||
textDiffDeletedBackground: "rgba(248,81,73,0.15)",
|
textDiffDeletedBackground: "rgba(248,81,73,0.15)",
|
||||||
placeholder: colors.slateDark,
|
placeholder: "#596673",
|
||||||
sidebarBackground: colors.veryDarkBlue,
|
sidebarBackground: colors.veryDarkBlue,
|
||||||
sidebarActiveBackground: lighten(0.02, colors.almostBlack),
|
sidebarActiveBackground: lighten(0.02, colors.almostBlack),
|
||||||
sidebarControlHoverBackground: colors.white10,
|
sidebarControlHoverBackground: colors.white10,
|
||||||
|
|||||||
Reference in New Issue
Block a user