From cae013837b3d538567e2698411f08622e140ec60 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Thu, 16 May 2024 19:45:09 -0400 Subject: [PATCH] Update collection permissions UI (#6917) --- app/actions/definitions/collections.tsx | 4 +- app/actions/definitions/developer.tsx | 40 ++- app/actions/definitions/documents.tsx | 2 +- app/components/Collection/CollectionForm.tsx | 23 +- .../InputMemberPermissionSelect.tsx | 13 +- app/components/InputSelect.tsx | 26 +- .../Collection/CollectionMemberList.tsx | 176 +++++++++ .../Sharing/Collection/SharePopover.tsx | 340 ++++++++++++++++++ .../{ => Document}/DocumentMemberList.tsx | 21 +- .../DocumentMemberListItem.tsx} | 29 +- .../Sharing/{ => Document}/OtherAccess.tsx | 20 +- .../Sharing/{ => Document}/PublicAccess.tsx | 16 +- .../Sharing/{ => Document}/SharePopover.tsx | 168 +++------ .../Sharing/{ => Document}/index.tsx | 0 .../Sharing/components/ListItem.tsx | 21 ++ .../Sharing/components/SearchInput.tsx | 63 ++++ .../Suggestions.tsx} | 93 +++-- app/components/Sharing/components/index.tsx | 67 ++++ app/models/Membership.ts | 9 + app/models/Share.ts | 10 + .../Collection/{ => components}/Actions.tsx | 0 .../{ => components}/DropToImport.tsx | 0 .../Collection/{ => components}/Empty.tsx | 19 +- .../{ => components}/MembershipPreview.tsx | 16 +- .../Collection/components/ShareButton.tsx | 60 ++++ .../{Collection.tsx => Collection/index.tsx} | 36 +- .../Document/components/ShareButton.tsx | 11 +- app/stores/CollectionGroupMembershipsStore.ts | 3 + app/stores/MembershipsStore.ts | 5 + app/stores/SharesStore.ts | 3 + app/types.ts | 1 + app/utils/FeatureFlags.ts | 43 +++ shared/i18n/locales/en_US/translation.json | 35 +- shared/styles/theme.ts | 2 +- 34 files changed, 1088 insertions(+), 287 deletions(-) create mode 100644 app/components/Sharing/Collection/CollectionMemberList.tsx create mode 100644 app/components/Sharing/Collection/SharePopover.tsx rename app/components/Sharing/{ => Document}/DocumentMemberList.tsx (86%) rename app/components/Sharing/{MemberListItem.tsx => Document/DocumentMemberListItem.tsx} (86%) rename app/components/Sharing/{ => Document}/OtherAccess.tsx (92%) rename app/components/Sharing/{ => Document}/PublicAccess.tsx (94%) rename app/components/Sharing/{ => Document}/SharePopover.tsx (69%) rename app/components/Sharing/{ => Document}/index.tsx (100%) create mode 100644 app/components/Sharing/components/ListItem.tsx create mode 100644 app/components/Sharing/components/SearchInput.tsx rename app/components/Sharing/{UserSuggestions.tsx => components/Suggestions.tsx} (62%) create mode 100644 app/components/Sharing/components/index.tsx rename app/scenes/Collection/{ => components}/Actions.tsx (100%) rename app/scenes/Collection/{ => components}/DropToImport.tsx (100%) rename app/scenes/Collection/{ => components}/Empty.tsx (83%) rename app/scenes/Collection/{ => components}/MembershipPreview.tsx (90%) create mode 100644 app/scenes/Collection/components/ShareButton.tsx rename app/scenes/{Collection.tsx => Collection/index.tsx} (90%) create mode 100644 app/utils/FeatureFlags.ts diff --git a/app/actions/definitions/collections.tsx b/app/actions/definitions/collections.tsx index d8b5c8b66..ab083c275 100644 --- a/app/actions/definitions/collections.tsx +++ b/app/actions/definitions/collections.tsx @@ -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: , visible: ({ stores, activeCollectionId }) => !!activeCollectionId && - stores.policies.abilities(activeCollectionId).update, + stores.policies.abilities(activeCollectionId).update && + !FeatureFlags.isEnabled(Feature.newCollectionSharing), perform: ({ t, activeCollectionId }) => { if (!activeCollectionId) { return; diff --git a/app/actions/definitions/developer.tsx b/app/actions/definitions/developer.tsx index e9cddd4df..e0d32e759 100644 --- a/app/actions/definitions/developer.tsx +++ b/app/actions/definitions/developer.tsx @@ -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: , 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: , + 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, ], }); diff --git a/app/actions/definitions/documents.tsx b/app/actions/definitions/documents.tsx index 8c865166b..46975e985 100644 --- a/app/actions/definitions/documents.tsx +++ b/app/actions/definitions/documents.tsx @@ -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"; diff --git a/app/components/Collection/CollectionForm.tsx b/app/components/Collection/CollectionForm.tsx index 26faae15c..eb1fa3868 100644 --- a/app/components/Collection/CollectionForm.tsx +++ b/app/components/Collection/CollectionForm.tsx @@ -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 && ( - - )} + {team.sharing && + (!collection || + FeatureFlags.isEnabled(Feature.newCollectionSharing)) && ( + + )} - + {FeatureFlags.isEnabled(Feature.newCollectionSharing) ? null : ( + + )} )} diff --git a/app/scenes/Collection/MembershipPreview.tsx b/app/scenes/Collection/components/MembershipPreview.tsx similarity index 90% rename from app/scenes/Collection/MembershipPreview.tsx rename to app/scenes/Collection/components/MembershipPreview.tsx index d8bbbf6f6..2941f2e12 100644 --- a/app/scenes/Collection/MembershipPreview.tsx +++ b/app/scenes/Collection/components/MembershipPreview.tsx @@ -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 ( 0 @@ -104,16 +107,11 @@ const MembershipPreview = ({ collection, limit = 8 }: Props) => { users={sortBy(collectionUsers, "lastActiveAt")} overflow={overflow} limit={limit} - renderAvatar={(user) => } + renderAvatar={(user) => } /> ); }; -const StyledAvatar = styled(Avatar)<{ model: User }>` - transition: opacity 250ms ease-in-out; - opacity: ${(props) => (props.model.isRecentlyActive ? 1 : 0.5)}; -`; - export default observer(MembershipPreview); diff --git a/app/scenes/Collection/components/ShareButton.tsx b/app/scenes/Collection/components/ShareButton.tsx new file mode 100644 index 000000000..705dc99c7 --- /dev/null +++ b/app/scenes/Collection/components/ShareButton.tsx @@ -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 ? ( + + ) : collection.permission ? undefined : ( + + ); + + return ( + <> + + {(props) => ( + + )} + + + + + + + ); +} + +export default observer(ShareButton); diff --git a/app/scenes/Collection.tsx b/app/scenes/Collection/index.tsx similarity index 90% rename from app/scenes/Collection.tsx rename to app/scenes/Collection/index.tsx index e0e42d798..f2b3e1e2d 100644 --- a/app/scenes/Collection.tsx +++ b/app/scenes/Collection/index.tsx @@ -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={ <> + + {FeatureFlags.isEnabled(Feature.newCollectionSharing) && + can.update && } + } @@ -159,16 +166,17 @@ function CollectionScene() { {collection.name} - {collection.isPrivate && ( - - {t("Private")} - - )} + {collection.isPrivate && + !FeatureFlags.isEnabled(Feature.newCollectionSharing) && ( + + {t("Private")} + + )} : undefined; + return ( <> {(props) => ( - )} diff --git a/app/stores/CollectionGroupMembershipsStore.ts b/app/stores/CollectionGroupMembershipsStore.ts index 5c4f02111..c8e567d50 100644 --- a/app/stores/CollectionGroupMembershipsStore.ts +++ b/app/stores/CollectionGroupMembershipsStore.ts @@ -76,4 +76,7 @@ export default class CollectionGroupMembershipsStore extends Store + this.orderedData.filter((cgm) => cgm.collectionId === collectionId); } diff --git a/app/stores/MembershipsStore.ts b/app/stores/MembershipsStore.ts index 843524125..6210d7442 100644 --- a/app/stores/MembershipsStore.ts +++ b/app/stores/MembershipsStore.ts @@ -82,4 +82,9 @@ export default class MembershipsStore extends Store { } }); }; + + inCollection = (collectionId: string) => + this.orderedData.filter( + (membership) => membership.collectionId === collectionId + ); } diff --git a/app/stores/SharesStore.ts b/app/stores/SharesStore.ts index 3ad250fe3..02d2c4034 100644 --- a/app/stores/SharesStore.ts +++ b/app/stores/SharesStore.ts @@ -104,6 +104,9 @@ export default class SharesStore extends Store { 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); } diff --git a/app/types.ts b/app/types.ts index c5754358d..323948fb6 100644 --- a/app/types.ts +++ b/app/types.ts @@ -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 diff --git a/app/utils/FeatureFlags.ts b/app/utils/FeatureFlags.ts new file mode 100644 index 000000000..614b2c88e --- /dev/null +++ b/app/utils/FeatureFlags.ts @@ -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 = new Set(); + + private static initalized = false; +} diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 83487c75e..027fc063a 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -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": "Has access through <2>parent", "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", "{{ collectionName }} doesn’t contain any\n documents yet.": "{{ collectionName }} 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> ago": "Active <1> ago", "Never signed in": "Never signed in", "{{ userName }} was removed from the collection": "{{ userName }} was removed from the collection", diff --git a/shared/styles/theme.ts b/shared/styles/theme.ts index 7ade24ab4..90068e4aa 100644 --- a/shared/styles/theme.ts +++ b/shared/styles/theme.ts @@ -182,7 +182,7 @@ export const buildDarkTheme = (input: Partial): 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,