From 0fd576cdd5031e3f1e67928c18cfb6dfab564825 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sun, 11 Sep 2022 14:54:57 +0200 Subject: [PATCH] feat: Updated collection header (#4101) * Return total results from collection membership endpoints * Display membership preview on collections * fix permissions * Revert unneccessary changes --- app/actions/definitions/collections.tsx | 25 +++++- app/components/Facepile.tsx | 10 ++- app/components/GroupListItem.tsx | 11 ++- app/components/Header.tsx | 10 +-- app/components/Input.tsx | 4 +- app/components/NudeButton.tsx | 14 +++- app/components/Scene.tsx | 6 +- app/menus/CollectionMenu.tsx | 53 ++++-------- app/scenes/Collection.tsx | 19 ++++- app/scenes/Collection/Actions.tsx | 47 ++++------- app/scenes/Collection/Empty.tsx | 30 +++---- app/scenes/Collection/MembershipPreview.tsx | 81 +++++++++++++++++++ app/scenes/CollectionPermissions/index.tsx | 9 ++- app/scenes/Document/components/Header.tsx | 4 +- app/scenes/Home.tsx | 14 ++-- app/stores/CollectionGroupMembershipsStore.ts | 10 ++- app/stores/MembershipsStore.ts | 9 ++- server/routes/api/collections.ts | 67 ++++++++------- shared/i18n/locales/en_US/translation.json | 9 ++- 19 files changed, 267 insertions(+), 165 deletions(-) create mode 100644 app/scenes/Collection/MembershipPreview.tsx diff --git a/app/actions/definitions/collections.tsx b/app/actions/definitions/collections.tsx index a151d2c79..9f127334f 100644 --- a/app/actions/definitions/collections.tsx +++ b/app/actions/definitions/collections.tsx @@ -1,6 +1,7 @@ import { CollectionIcon, EditIcon, + PadlockIcon, PlusIcon, StarredIcon, UnstarredIcon, @@ -10,6 +11,7 @@ import stores from "~/stores"; import Collection from "~/models/Collection"; import CollectionEdit from "~/scenes/CollectionEdit"; import CollectionNew from "~/scenes/CollectionNew"; +import CollectionPermissions from "~/scenes/CollectionPermissions"; import DynamicCollectionIcon from "~/components/CollectionIcon"; import { createAction } from "~/actions"; import { CollectionSection } from "~/actions/sections"; @@ -56,7 +58,8 @@ export const createCollection = createAction({ }); export const editCollection = createAction({ - name: ({ t }) => t("Edit collection"), + name: ({ t, isContextMenu }) => + isContextMenu ? `${t("Edit")}…` : t("Edit collection"), section: CollectionSection, icon: , visible: ({ stores, activeCollectionId }) => @@ -79,6 +82,26 @@ export const editCollection = createAction({ }, }); +export const editCollectionPermissions = createAction({ + name: ({ t, isContextMenu }) => + isContextMenu ? `${t("Permissions")}…` : t("Collection permissions"), + section: CollectionSection, + icon: , + visible: ({ stores, activeCollectionId }) => + !!activeCollectionId && + stores.policies.abilities(activeCollectionId).update, + perform: ({ t, activeCollectionId }) => { + if (!activeCollectionId) { + return; + } + + stores.dialogs.openModal({ + title: t("Collection permissions"), + content: , + }); + }, +}); + export const starCollection = createAction({ name: ({ t }) => t("Star"), section: CollectionSection, diff --git a/app/components/Facepile.tsx b/app/components/Facepile.tsx index 9a4e48b41..808bb9e39 100644 --- a/app/components/Facepile.tsx +++ b/app/components/Facepile.tsx @@ -9,7 +9,7 @@ type Props = { users: User[]; size?: number; overflow?: number; - onClick?: React.MouseEventHandler; + limit?: number; renderAvatar?: (user: User) => React.ReactNode; }; @@ -17,6 +17,7 @@ function Facepile({ users, overflow = 0, size = 32, + limit = 8, renderAvatar = DefaultAvatar, ...rest }: Props) { @@ -24,10 +25,13 @@ function Facepile({ {overflow > 0 && ( - +{overflow} + + {users.length ? "+" : ""} + {overflow} + )} - {users.map((user) => ( + {users.slice(0, limit).map((user) => ( {renderAvatar(user)} ))} diff --git a/app/components/GroupListItem.tsx b/app/components/GroupListItem.tsx index e23fb5eb4..350b054d4 100644 --- a/app/components/GroupListItem.tsx +++ b/app/components/GroupListItem.tsx @@ -13,6 +13,7 @@ import Flex from "~/components/Flex"; import ListItem from "~/components/List/Item"; import Modal from "~/components/Modal"; import withStores from "~/components/withStores"; +import NudeButton from "./NudeButton"; type Props = RootStore & { group: Group; @@ -63,11 +64,13 @@ class GroupListItem extends React.Component { actions={ {showFacepile && ( - + > + + )} {renderActions({ openMembersModal: this.handleMembersModalOpen, diff --git a/app/components/Header.tsx b/app/components/Header.tsx index 775813e68..f5ea22d28 100644 --- a/app/components/Header.tsx +++ b/app/components/Header.tsx @@ -15,19 +15,19 @@ import useStores from "~/hooks/useStores"; import { supportsPassiveListener } from "~/utils/browser"; type Props = { - breadcrumb?: React.ReactNode; + left?: React.ReactNode; title: React.ReactNode; actions?: React.ReactNode; hasSidebar?: boolean; }; -function Header({ breadcrumb, title, actions, hasSidebar }: Props) { +function Header({ left, title, actions, hasSidebar }: Props) { const { ui } = useStores(); const isMobile = useMobile(); const hasMobileSidebar = hasSidebar && isMobile; - const passThrough = !actions && !breadcrumb && !title; + const passThrough = !actions && !left && !title; const [isScrolled, setScrolled] = React.useState(false); const handleScroll = React.useMemo( @@ -51,7 +51,7 @@ function Header({ breadcrumb, title, actions, hasSidebar }: Props) { return ( - {breadcrumb || hasMobileSidebar ? ( + {left || hasMobileSidebar ? ( {hasMobileSidebar && ( )} - {breadcrumb} + {left} ) : null} diff --git a/app/components/Input.tsx b/app/components/Input.tsx index 41d15a069..e9e011bd6 100644 --- a/app/components/Input.tsx +++ b/app/components/Input.tsx @@ -173,7 +173,7 @@ class Input extends React.Component { {type === "textarea" ? ( { ) : ( ({ type: "type" in props ? props.type : "button", }))` - width: ${(props) => props.width || props.size || 24}px; - height: ${(props) => props.height || props.size || 24}px; + width: ${(props) => + typeof props.width === "string" + ? props.width + : `${props.width || props.size || 24}px`}; + height: ${(props) => + typeof props.height === "string" + ? props.height + : `${props.width || props.size || 24}px`}; background: none; border-radius: 4px; display: inline-block; diff --git a/app/components/Scene.tsx b/app/components/Scene.tsx index 3d28a21e1..9aa83b2ff 100644 --- a/app/components/Scene.tsx +++ b/app/components/Scene.tsx @@ -8,7 +8,7 @@ type Props = { icon?: React.ReactNode; title?: React.ReactNode; textTitle?: string; - breadcrumb?: React.ReactNode; + left?: React.ReactNode; actions?: React.ReactNode; centered?: boolean; }; @@ -18,7 +18,7 @@ const Scene: React.FC = ({ icon, textTitle, actions, - breadcrumb, + left, children, centered, }) => { @@ -37,7 +37,7 @@ const Scene: React.FC = ({ ) } actions={actions} - breadcrumb={breadcrumb} + left={left} /> {centered !== false ? ( {children} diff --git a/app/menus/CollectionMenu.tsx b/app/menus/CollectionMenu.tsx index faca20f54..ed775758d 100644 --- a/app/menus/CollectionMenu.tsx +++ b/app/menus/CollectionMenu.tsx @@ -1,11 +1,9 @@ import { observer } from "mobx-react"; import { NewDocumentIcon, - EditIcon, TrashIcon, ImportIcon, ExportIcon, - PadlockIcon, AlphabeticalSortIcon, ManualSortIcon, UnstarredIcon, @@ -18,13 +16,17 @@ import { useMenuState, MenuButton, MenuButtonHTMLProps } from "reakit/Menu"; import { VisuallyHidden } from "reakit/VisuallyHidden"; import { getEventFiles } from "@shared/utils/files"; import Collection from "~/models/Collection"; -import CollectionEdit from "~/scenes/CollectionEdit"; import CollectionExport from "~/scenes/CollectionExport"; -import CollectionPermissions from "~/scenes/CollectionPermissions"; import CollectionDeleteDialog from "~/components/CollectionDeleteDialog"; import ContextMenu, { Placement } from "~/components/ContextMenu"; import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton"; import Template from "~/components/ContextMenu/Template"; +import { actionToMenuItem } from "~/actions"; +import { + editCollection, + editCollectionPermissions, +} from "~/actions/definitions/collections"; +import useActionContext from "~/hooks/useActionContext"; import useCurrentTeam from "~/hooks/useCurrentTeam"; import usePolicy from "~/hooks/usePolicy"; import useStores from "~/hooks/useStores"; @@ -60,25 +62,6 @@ function CollectionMenu({ const history = useHistory(); const file = React.useRef(null); - const handlePermissions = React.useCallback(() => { - dialogs.openModal({ - title: t("Collection permissions"), - content: , - }); - }, [collection, dialogs, t]); - - const handleEdit = React.useCallback(() => { - dialogs.openModal({ - title: t("Edit collection"), - content: ( - - ), - }); - }, [collection.id, dialogs, t]); - const handleExport = React.useCallback(() => { dialogs.openModal({ title: t("Export collection"), @@ -186,6 +169,11 @@ function CollectionMenu({ [collection] ); + const context = useActionContext({ + isContextMenu: true, + activeCollectionId: collection.id, + }); + const alphabeticalSort = collection.sort.field === "title"; const can = usePolicy(collection); const canUserInTeam = usePolicy(team); @@ -225,6 +213,8 @@ function CollectionMenu({ { type: "separator", }, + actionToMenuItem(editCollection, context), + actionToMenuItem(editCollectionPermissions, context), { type: "submenu", title: t("Sort in sidebar"), @@ -249,20 +239,6 @@ function CollectionMenu({ }, ], }, - { - type: "button", - title: `${t("Edit")}…`, - visible: can.update, - onClick: handleEdit, - icon: , - }, - { - type: "button", - title: `${t("Permissions")}…`, - visible: can.update, - onClick: handlePermissions, - icon: , - }, { type: "button", title: `${t("Export")}…`, @@ -293,9 +269,8 @@ function CollectionMenu({ handleStar, handleNewDocument, handleImportDocument, + context, alphabeticalSort, - handleEdit, - handlePermissions, canUserInTeam.createExport, handleExport, handleDelete, diff --git a/app/scenes/Collection.tsx b/app/scenes/Collection.tsx index 2c81ea2f1..5cd993d64 100644 --- a/app/scenes/Collection.tsx +++ b/app/scenes/Collection.tsx @@ -18,6 +18,7 @@ import CenteredContent from "~/components/CenteredContent"; import CollectionDescription from "~/components/CollectionDescription"; import CollectionIcon from "~/components/CollectionIcon"; import Heading from "~/components/Heading"; +import InputSearchPage from "~/components/InputSearchPage"; import PlaceholderList from "~/components/List/Placeholder"; import PaginatedDocumentList from "~/components/PaginatedDocumentList"; import PinnedDocuments from "~/components/PinnedDocuments"; @@ -35,6 +36,7 @@ import { collectionUrl, updateCollectionUrl } from "~/utils/routeHelpers"; import Actions from "./Collection/Actions"; import DropToImport from "./Collection/DropToImport"; import Empty from "./Collection/Empty"; +import MembershipPreview from "./Collection/MembershipPreview"; function CollectionScene() { const params = useParams<{ id?: string }>(); @@ -112,13 +114,28 @@ function CollectionScene() { key={collection.id} centered={false} textTitle={collection.name} + left={ + collection.isEmpty ? undefined : ( + + ) + } title={ <>  {collection.name} } - actions={} + actions={ + <> + + + + } > - {!collection.isEmpty && ( + {can.update && ( <> - + + + - {can.update && ( - <> - - - - - - - - )} + )} diff --git a/app/scenes/Collection/Empty.tsx b/app/scenes/Collection/Empty.tsx index 883c4b5fb..a46c69757 100644 --- a/app/scenes/Collection/Empty.tsx +++ b/app/scenes/Collection/Empty.tsx @@ -5,12 +5,11 @@ import { Trans, useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; import styled from "styled-components"; import Collection from "~/models/Collection"; -import CollectionPermissions from "~/scenes/CollectionPermissions"; import Button from "~/components/Button"; import Flex from "~/components/Flex"; -import Modal from "~/components/Modal"; import Text from "~/components/Text"; -import useBoolean from "~/hooks/useBoolean"; +import { editCollectionPermissions } from "~/actions/definitions/collections"; +import useActionContext from "~/hooks/useActionContext"; import usePolicy from "~/hooks/usePolicy"; import { newDocumentPath } from "~/utils/routeHelpers"; @@ -21,13 +20,10 @@ type Props = { function EmptyCollection({ collection }: Props) { const { t } = useTranslation(); const can = usePolicy(collection); + const context = useActionContext(); const collectionName = collection ? collection.name : ""; - const [ - permissionsModalOpen, - handlePermissionsModalOpen, - handlePermissionsModalClose, - ] = useBoolean(); + console.log({ context }); return ( @@ -48,23 +44,20 @@ function EmptyCollection({ collection }: Props) { {can.update && ( - -    - )} - - - ); } @@ -79,6 +72,7 @@ const Centered = styled(Flex)` const Empty = styled(Flex)` justify-content: center; margin: 10px 0; + gap: 8px; `; export default observer(EmptyCollection); diff --git a/app/scenes/Collection/MembershipPreview.tsx b/app/scenes/Collection/MembershipPreview.tsx new file mode 100644 index 000000000..0d8edb0f3 --- /dev/null +++ b/app/scenes/Collection/MembershipPreview.tsx @@ -0,0 +1,81 @@ +import { observer } from "mobx-react"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { PAGINATION_SYMBOL } from "~/stores/BaseStore"; +import Collection from "~/models/Collection"; +import Facepile from "~/components/Facepile"; +import Fade from "~/components/Fade"; +import NudeButton from "~/components/NudeButton"; +import { editCollectionPermissions } from "~/actions/definitions/collections"; +import useActionContext from "~/hooks/useActionContext"; +import useStores from "~/hooks/useStores"; + +type Props = { + collection: Collection; +}; + +const MembershipPreview = ({ collection }: Props) => { + const [isLoading, setIsLoading] = React.useState(false); + const [totalMemberships, setTotalMemberships] = React.useState(0); + const { t } = useTranslation(); + const { memberships, collectionGroupMemberships, users } = useStores(); + const collectionUsers = users.inCollection(collection.id); + const context = useActionContext(); + + React.useEffect(() => { + const fetchData = async () => { + if (collection.permission) { + return; + } + setIsLoading(true); + + try { + const options = { + id: collection.id, + limit: 8, + }; + const [users, groups] = await Promise.all([ + memberships.fetchPage(options), + collectionGroupMemberships.fetchPage(options), + ]); + setTotalMemberships( + users[PAGINATION_SYMBOL].total + groups[PAGINATION_SYMBOL].total + ); + } finally { + setIsLoading(false); + } + }; + + fetchData(); + }, [ + collection.permission, + collection.id, + collectionGroupMemberships, + memberships, + ]); + + if (isLoading || collection.permission) { + return null; + } + + const overflow = totalMemberships - collectionUsers.length; + + return ( + + + + + + ); +}; + +export default observer(MembershipPreview); diff --git a/app/scenes/CollectionPermissions/index.tsx b/app/scenes/CollectionPermissions/index.tsx index b10977f4e..627276016 100644 --- a/app/scenes/CollectionPermissions/index.tsx +++ b/app/scenes/CollectionPermissions/index.tsx @@ -1,10 +1,10 @@ +import invariant from "invariant"; import { observer } from "mobx-react"; import { PlusIcon } from "outline-icons"; import * as React from "react"; import { useTranslation, Trans } from "react-i18next"; import styled from "styled-components"; import { CollectionPermission } from "@shared/types"; -import Collection from "~/models/Collection"; import Group from "~/models/Group"; import User from "~/models/User"; import Button from "~/components/Button"; @@ -26,13 +26,14 @@ import CollectionGroupMemberListItem from "./components/CollectionGroupMemberLis import MemberListItem from "./components/MemberListItem"; type Props = { - collection: Collection; + collectionId: string; }; -function CollectionPermissions({ collection }: Props) { +function CollectionPermissions({ collectionId }: Props) { const { t } = useTranslation(); const user = useCurrentUser(); const { + collections, memberships, collectionGroupMemberships, users, @@ -40,6 +41,8 @@ function CollectionPermissions({ collection }: Props) { auth, } = useStores(); const { showToast } = useToasts(); + const collection = collections.get(collectionId); + invariant(collection, "Collection not found"); const [ addGroupModalOpen, diff --git a/app/scenes/Document/components/Header.tsx b/app/scenes/Document/components/Header.tsx index ee9a56aeb..02af12cb6 100644 --- a/app/scenes/Document/components/Header.tsx +++ b/app/scenes/Document/components/Header.tsx @@ -174,7 +174,7 @@ function DocumentHeader({
) : ( @@ -201,7 +201,7 @@ function DocumentHeader({ <>
) : ( diff --git a/app/scenes/Home.tsx b/app/scenes/Home.tsx index cf6445e64..bccf3cb3e 100644 --- a/app/scenes/Home.tsx +++ b/app/scenes/Home.tsx @@ -36,15 +36,13 @@ function Home() { } title={t("Home")} + left={ + + } actions={ - <> - - - - - - - + + + } > {!ui.languagePromptDismissed && } diff --git a/app/stores/CollectionGroupMembershipsStore.ts b/app/stores/CollectionGroupMembershipsStore.ts index 3e689c861..e18dc5af5 100644 --- a/app/stores/CollectionGroupMembershipsStore.ts +++ b/app/stores/CollectionGroupMembershipsStore.ts @@ -4,7 +4,7 @@ import { CollectionPermission } from "@shared/types"; import CollectionGroupMembership from "~/models/CollectionGroupMembership"; import { PaginationParams } from "~/types"; import { client } from "~/utils/ApiClient"; -import BaseStore, { RPCAction } from "./BaseStore"; +import BaseStore, { PAGINATION_SYMBOL, RPCAction } from "./BaseStore"; import RootStore from "./RootStore"; export default class CollectionGroupMembershipsStore extends BaseStore< @@ -26,13 +26,15 @@ export default class CollectionGroupMembershipsStore extends BaseStore< const res = await client.post(`/collections.group_memberships`, params); invariant(res?.data, "Data not available"); - let models: CollectionGroupMembership[] = []; + let response: CollectionGroupMembership[] = []; runInAction(`CollectionGroupMembershipsStore#fetchPage`, () => { res.data.groups.forEach(this.rootStore.groups.add); - models = res.data.collectionGroupMemberships.map(this.add); + response = res.data.collectionGroupMemberships.map(this.add); this.isLoaded = true; }); - return models; + + response[PAGINATION_SYMBOL] = res.pagination; + return response; } finally { this.isFetching = false; } diff --git a/app/stores/MembershipsStore.ts b/app/stores/MembershipsStore.ts index 5730752fe..571cb85f8 100644 --- a/app/stores/MembershipsStore.ts +++ b/app/stores/MembershipsStore.ts @@ -4,7 +4,7 @@ import { CollectionPermission } from "@shared/types"; import Membership from "~/models/Membership"; import { PaginationParams } from "~/types"; import { client } from "~/utils/ApiClient"; -import BaseStore, { RPCAction } from "./BaseStore"; +import BaseStore, { PAGINATION_SYMBOL, RPCAction } from "./BaseStore"; import RootStore from "./RootStore"; export default class MembershipsStore extends BaseStore { @@ -24,13 +24,14 @@ export default class MembershipsStore extends BaseStore { const res = await client.post(`/collections.memberships`, params); invariant(res?.data, "Data not available"); - let models: Membership[] = []; + let response: Membership[] = []; runInAction(`MembershipsStore#fetchPage`, () => { res.data.users.forEach(this.rootStore.users.add); - models = res.data.memberships.map(this.add); + response = res.data.memberships.map(this.add); this.isLoaded = true; }); - return models; + response[PAGINATION_SYMBOL] = res.pagination; + return response; } finally { this.isFetching = false; } diff --git a/server/routes/api/collections.ts b/server/routes/api/collections.ts index 684d51683..93637705c 100644 --- a/server/routes/api/collections.ts +++ b/server/routes/api/collections.ts @@ -316,22 +316,26 @@ router.post( where = { ...where, permission }; } - const memberships = await CollectionGroup.findAll({ - where, - order: [["createdAt", "DESC"]], - offset: ctx.state.pagination.offset, - limit: ctx.state.pagination.limit, - include: [ - { - model: Group, - as: "group", - where: groupWhere, - required: true, - }, - ], - }); + const [total, memberships] = await Promise.all([ + CollectionGroup.count({ where }), + CollectionGroup.findAll({ + where, + order: [["createdAt", "DESC"]], + offset: ctx.state.pagination.offset, + limit: ctx.state.pagination.limit, + include: [ + { + model: Group, + as: "group", + where: groupWhere, + required: true, + }, + ], + }), + ]); + ctx.body = { - pagination: ctx.state.pagination, + pagination: { ...ctx.state.pagination, total }, data: { collectionGroupMemberships: memberships.map( presentCollectionGroupMembership @@ -457,23 +461,26 @@ router.post("collections.memberships", auth(), pagination(), async (ctx) => { where = { ...where, permission }; } - const memberships = await CollectionUser.findAll({ - where, - order: [["createdAt", "DESC"]], - offset: ctx.state.pagination.offset, - limit: ctx.state.pagination.limit, - include: [ - { - model: User, - as: "user", - where: userWhere, - required: true, - }, - ], - }); + const [total, memberships] = await Promise.all([ + CollectionUser.count({ where }), + CollectionUser.findAll({ + where, + order: [["createdAt", "DESC"]], + offset: ctx.state.pagination.offset, + limit: ctx.state.pagination.limit, + include: [ + { + model: User, + as: "user", + where: userWhere, + required: true, + }, + ], + }), + ]); ctx.body = { - pagination: ctx.state.pagination, + pagination: { ...ctx.state.pagination, total }, data: { memberships: memberships.map(presentMembership), users: memberships.map((membership) => presentUser(membership.user)), diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index ffd0f0e96..253b92859 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -2,7 +2,10 @@ "Open collection": "Open collection", "New collection": "New collection", "Create a collection": "Create a collection", + "Edit": "Edit", "Edit collection": "Edit collection", + "Permissions": "Permissions", + "Collection permissions": "Collection permissions", "Star": "Star", "Unstar": "Unstar", "Delete IndexedDB cache": "Delete IndexedDB cache", @@ -284,14 +287,11 @@ "Path to document": "Path to document", "Group member options": "Group member options", "Remove": "Remove", - "Collection permissions": "Collection permissions", "Export collection": "Export collection", "Delete collection": "Are you sure you want to delete this collection?", "Sort in sidebar": "Sort in sidebar", "Alphabetical sort": "Alphabetical sort", "Manual sort": "Manual sort", - "Edit": "Edit", - "Permissions": "Permissions", "Document unpublished": "Document unpublished", "Document options": "Document options", "Restore": "Restore", @@ -334,19 +334,20 @@ "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.", + "Search in collection": "Search in collection", "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", - "Search in collection": "Search in collection", "Collection menu": "Collection menu", "Drop documents to import": "Drop documents to import", "{{ collectionName }} doesn’t contain any\n documents yet.": "{{ collectionName }} doesn’t contain any\n documents yet.", "Get started by creating a new one!": "Get started by creating a new one!", "Create a document": "Create a document", "Manage permissions": "Manage permissions", + "Users and groups with access": "Users and groups with access", "The collection was updated": "The collection was updated", "You can edit the name and other details at any time, however doing so often might confuse your team mates.": "You can edit the name and other details at any time, however doing so often might confuse your team mates.", "Name": "Name",