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