feat: Memberships (#1032)
* WIP * feat: Add collection.memberships endpoint * feat: Add ability to filter collection.memberships with query * WIP * Merge stashed work * feat: Add ability to filter memberships by permission * continued refactoring * paginated list component * Collection member management * fix: Incorrect policy data sent down after collection.update * Reduce duplication, add empty state * cleanup * fix: Modal close should be a real button * fix: Allow opening edit from modal * fix: remove unused methods * test: fix * Passing test suite * Refactor * fix: Flow UI errors * test: Add collections.update tests * lint * test: moar tests * fix: Missing scopes, more missing tests * fix: Handle collection privacy change over socket * fix: More membership scopes * fix: view endpoint permissions * fix: respond to privacy change on socket event * policy driven menus * fix: share endpoint policies * chore: Use policies to drive documents UI * alignment * fix: Header height * fix: Correct behavior when collection becomes private * fix: Header height for read-only collection * send id's over socket instead of serialized objects * fix: Remote policy change * fix: reduce collection fetching * More websocket efficiencies * fix: Document collection pinning * fix: Restored ability to edit drafts fix: Removed ability to star drafts * fix: Require write permissions to pin doc to collection * fix: Header title overlaying document actions at small screen sizes * fix: Jank on load caused by previous commit * fix: Double collection fetch post-publish * fix: Hide publish button if draft is in no longer accessible collection * fix: Always allow deleting drafts fix: Improved handling of deleted documents * feat: Show collections in drafts view feat: Show more obvious 'draft' badge on documents * fix: incorrect policies after publish to private collection * fix: Duplicating a draft publishes it
This commit is contained in:
@@ -7,6 +7,7 @@ export const Action = styled(Flex)`
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0 0 0 12px;
|
||||
height: 32px;
|
||||
font-size: 15px;
|
||||
flex-shrink: 0;
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@ const Badge = styled.span`
|
||||
margin-left: 10px;
|
||||
padding: 2px 6px 3px;
|
||||
background-color: ${({ admin, theme }) =>
|
||||
admin ? theme.primary : theme.smokeDark};
|
||||
color: ${({ admin, theme }) => (admin ? theme.white : theme.text)};
|
||||
border-radius: 2px;
|
||||
admin ? theme.primary : theme.textTertiary};
|
||||
color: ${({ admin, theme }) => (admin ? theme.white : theme.background)};
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
|
||||
@@ -5,6 +5,8 @@ import { Link } from 'react-router-dom';
|
||||
import { StarredIcon } from 'outline-icons';
|
||||
import styled, { withTheme } from 'styled-components';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import Badge from 'components/Badge';
|
||||
import Tooltip from 'components/Tooltip';
|
||||
import Highlight from 'components/Highlight';
|
||||
import PublishingInfo from 'components/PublishingInfo';
|
||||
import DocumentMenu from 'menus/DocumentMenu';
|
||||
@@ -17,6 +19,7 @@ type Props = {
|
||||
showCollection?: boolean,
|
||||
showPublished?: boolean,
|
||||
showPin?: boolean,
|
||||
showDraft?: boolean,
|
||||
};
|
||||
|
||||
const StyledStar = withTheme(styled(({ solid, theme, ...props }) => (
|
||||
@@ -130,6 +133,7 @@ class DocumentPreview extends React.Component<Props> {
|
||||
showCollection,
|
||||
showPublished,
|
||||
showPin,
|
||||
showDraft = true,
|
||||
highlight,
|
||||
context,
|
||||
...rest
|
||||
@@ -159,6 +163,16 @@ class DocumentPreview extends React.Component<Props> {
|
||||
)}
|
||||
</Actions>
|
||||
)}
|
||||
{document.isDraft &&
|
||||
showDraft && (
|
||||
<Tooltip
|
||||
tooltip="Only visible to you"
|
||||
delay={500}
|
||||
placement="top"
|
||||
>
|
||||
<Badge>Draft</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
<StyledDocumentMenu document={document} showPin={showPin} />
|
||||
</Heading>
|
||||
{!queryIsInTitle && (
|
||||
|
||||
@@ -4,9 +4,11 @@ import invariant from 'invariant';
|
||||
import { observable } from 'mobx';
|
||||
import { observer } from 'mobx-react';
|
||||
import { PortalWithState } from 'react-portal';
|
||||
import { MoreIcon } from 'outline-icons';
|
||||
import styled from 'styled-components';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import { fadeAndScaleIn } from 'shared/styles/animations';
|
||||
import NudeButton from 'components/NudeButton';
|
||||
|
||||
let previousClosePortal;
|
||||
|
||||
@@ -15,7 +17,7 @@ type Children =
|
||||
| ((options: { closePortal: () => void }) => React.Node);
|
||||
|
||||
type Props = {
|
||||
label: React.Node,
|
||||
label?: React.Node,
|
||||
onOpen?: () => void,
|
||||
onClose?: () => void,
|
||||
children?: Children,
|
||||
@@ -76,7 +78,11 @@ class DropdownMenu extends React.Component<Props> {
|
||||
{({ closePortal, openPortal, portal }) => (
|
||||
<React.Fragment>
|
||||
<Label onClick={this.handleOpen(openPortal, closePortal)}>
|
||||
{label}
|
||||
{label || (
|
||||
<NudeButton>
|
||||
<MoreIcon />
|
||||
</NudeButton>
|
||||
)}
|
||||
</Label>
|
||||
{portal(
|
||||
<Position
|
||||
|
||||
@@ -8,9 +8,13 @@ type Props = {
|
||||
disabled?: boolean,
|
||||
};
|
||||
|
||||
const DropdownMenuItem = ({ onClick, children, ...rest }: Props) => {
|
||||
const DropdownMenuItem = ({ onClick, children, disabled, ...rest }: Props) => {
|
||||
return (
|
||||
<MenuItem onClick={onClick} {...rest}>
|
||||
<MenuItem
|
||||
onClick={disabled ? undefined : onClick}
|
||||
disabled={disabled}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</MenuItem>
|
||||
);
|
||||
@@ -33,9 +37,13 @@ const MenuItem = styled.a`
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
svg {
|
||||
opacity: ${props => (props.disabled ? '.5' : 1)};
|
||||
}
|
||||
|
||||
${props =>
|
||||
props.disabled
|
||||
? ''
|
||||
? 'pointer-events: none;'
|
||||
: `
|
||||
|
||||
&:hover {
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as React from 'react';
|
||||
import { observable } from 'mobx';
|
||||
import { observer } from 'mobx-react';
|
||||
import styled, { withTheme } from 'styled-components';
|
||||
import Input, { LabelText, Outline } from 'components/Input';
|
||||
import { LabelText, Outline } from 'components/Input';
|
||||
|
||||
type Props = {
|
||||
label: string,
|
||||
@@ -41,26 +41,22 @@ class InputRich extends React.Component<Props> {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<LabelText>{label}</LabelText>
|
||||
{Editor ? (
|
||||
<StyledOutline
|
||||
maxHeight={maxHeight}
|
||||
minHeight={minHeight}
|
||||
focused={this.focused}
|
||||
>
|
||||
|
||||
<StyledOutline
|
||||
maxHeight={maxHeight}
|
||||
minHeight={minHeight}
|
||||
focused={this.focused}
|
||||
>
|
||||
{Editor ? (
|
||||
<Editor
|
||||
onBlur={this.handleBlur}
|
||||
onFocus={this.handleFocus}
|
||||
{...rest}
|
||||
/>
|
||||
</StyledOutline>
|
||||
) : (
|
||||
<Input
|
||||
maxHeight={maxHeight}
|
||||
minHeight={minHeight}
|
||||
placeholder="Loading…"
|
||||
disabled
|
||||
/>
|
||||
)}
|
||||
) : (
|
||||
'Loading…'
|
||||
)}
|
||||
</StyledOutline>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
72
app/components/InputSelect.js
Normal file
72
app/components/InputSelect.js
Normal file
@@ -0,0 +1,72 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { observer } from 'mobx-react';
|
||||
import { observable } from 'mobx';
|
||||
import styled from 'styled-components';
|
||||
import VisuallyHidden from 'components/VisuallyHidden';
|
||||
import { Outline, LabelText } from './Input';
|
||||
|
||||
const Select = styled.select`
|
||||
border: 0;
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
outline: none;
|
||||
background: none;
|
||||
color: ${props => props.theme.text};
|
||||
|
||||
&:disabled,
|
||||
&::placeholder {
|
||||
color: ${props => props.theme.placeholder};
|
||||
}
|
||||
`;
|
||||
|
||||
type Option = { label: string, value: string };
|
||||
|
||||
export type Props = {
|
||||
value?: string,
|
||||
label?: string,
|
||||
className?: string,
|
||||
labelHidden?: boolean,
|
||||
options: Option[],
|
||||
};
|
||||
|
||||
@observer
|
||||
class InputSelect extends React.Component<Props> {
|
||||
@observable focused: boolean = false;
|
||||
|
||||
handleBlur = () => {
|
||||
this.focused = false;
|
||||
};
|
||||
|
||||
handleFocus = () => {
|
||||
this.focused = true;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { label, className, labelHidden, options, ...rest } = this.props;
|
||||
|
||||
const wrappedLabel = <LabelText>{label}</LabelText>;
|
||||
|
||||
return (
|
||||
<label>
|
||||
{label &&
|
||||
(labelHidden ? (
|
||||
<VisuallyHidden>{wrappedLabel}</VisuallyHidden>
|
||||
) : (
|
||||
wrappedLabel
|
||||
))}
|
||||
<Outline focused={this.focused} className={className}>
|
||||
<Select onBlur={this.handleBlur} onFocus={this.handleFocus} {...rest}>
|
||||
{options.map(option => (
|
||||
<option value={option.value} key={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Outline>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default InputSelect;
|
||||
@@ -30,6 +30,10 @@ const Wrapper = styled.li`
|
||||
padding: ${props => (props.compact ? '8px' : '12px')} 0;
|
||||
margin: 0;
|
||||
border-bottom: 1px solid ${props => props.theme.divider};
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const Image = styled(Flex)`
|
||||
|
||||
@@ -6,6 +6,7 @@ import breakpoint from 'styled-components-breakpoint';
|
||||
import ReactModal from 'react-modal';
|
||||
import { transparentize } from 'polished';
|
||||
import { CloseIcon } from 'outline-icons';
|
||||
import NudeButton from 'components/NudeButton';
|
||||
import { fadeAndScaleIn } from 'shared/styles/animations';
|
||||
import Flex from 'shared/components/Flex';
|
||||
|
||||
@@ -90,16 +91,18 @@ const StyledModal = styled(ReactModal)`
|
||||
const Esc = styled.span`
|
||||
display: block;
|
||||
text-align: center;
|
||||
margin-top: -10px;
|
||||
font-size: 13px;
|
||||
height: 1em;
|
||||
`;
|
||||
|
||||
const Close = styled.a`
|
||||
const Close = styled(NudeButton)`
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
opacity: 0.75;
|
||||
color: ${props => props.theme.text};
|
||||
width: auto;
|
||||
height: auto;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
|
||||
95
app/components/PaginatedList.js
Normal file
95
app/components/PaginatedList.js
Normal file
@@ -0,0 +1,95 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { observable, action } from 'mobx';
|
||||
import { observer } from 'mobx-react';
|
||||
import Waypoint from 'react-waypoint';
|
||||
import ArrowKeyNavigation from 'boundless-arrow-key-navigation';
|
||||
|
||||
import { DEFAULT_PAGINATION_LIMIT } from 'stores/BaseStore';
|
||||
import { ListPlaceholder } from 'components/LoadingPlaceholder';
|
||||
|
||||
type Props = {
|
||||
fetch?: (options: ?Object) => Promise<void>,
|
||||
options?: Object,
|
||||
empty?: React.Node,
|
||||
items: any[],
|
||||
renderItem: any => React.Node,
|
||||
};
|
||||
|
||||
@observer
|
||||
class PaginatedList extends React.Component<Props> {
|
||||
isInitiallyLoaded: boolean = false;
|
||||
@observable isLoaded: boolean = false;
|
||||
@observable isFetchingMore: boolean = false;
|
||||
@observable isFetching: boolean = false;
|
||||
@observable offset: number = 0;
|
||||
@observable allowLoadMore: boolean = true;
|
||||
|
||||
componentDidMount() {
|
||||
this.isInitiallyLoaded = !!this.props.items.length;
|
||||
this.fetchResults();
|
||||
}
|
||||
|
||||
fetchResults = async () => {
|
||||
if (!this.props.fetch) return;
|
||||
|
||||
this.isFetching = true;
|
||||
|
||||
const limit = DEFAULT_PAGINATION_LIMIT;
|
||||
const results = await this.props.fetch({
|
||||
limit,
|
||||
offset: this.offset,
|
||||
...this.props.options,
|
||||
});
|
||||
|
||||
if (results && (results.length === 0 || results.length < limit)) {
|
||||
this.allowLoadMore = false;
|
||||
} else {
|
||||
this.offset += limit;
|
||||
}
|
||||
|
||||
this.isLoaded = true;
|
||||
this.isFetching = false;
|
||||
this.isFetchingMore = false;
|
||||
};
|
||||
|
||||
@action
|
||||
loadMoreResults = async () => {
|
||||
// Don't paginate if there aren't more results or we’re in the middle of fetching
|
||||
if (!this.allowLoadMore || this.isFetching) return;
|
||||
|
||||
this.isFetchingMore = true;
|
||||
await this.fetchResults();
|
||||
};
|
||||
|
||||
render() {
|
||||
const { items, empty } = this.props;
|
||||
|
||||
const showLoading =
|
||||
this.isFetching && !this.isFetchingMore && !this.isInitiallyLoaded;
|
||||
const showEmpty = !items.length || showLoading;
|
||||
const showList = (this.isLoaded || this.isInitiallyLoaded) && !showLoading;
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{showEmpty && empty}
|
||||
{showList && (
|
||||
<React.Fragment>
|
||||
<ArrowKeyNavigation
|
||||
mode={ArrowKeyNavigation.mode.VERTICAL}
|
||||
defaultActiveChildIndex={0}
|
||||
>
|
||||
{items.map(this.props.renderItem)}
|
||||
</ArrowKeyNavigation>
|
||||
{this.allowLoadMore && (
|
||||
<Waypoint key={this.offset} onEnter={this.loadMoreResults} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
)}
|
||||
{showLoading && <ListPlaceholder count={5} />}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default PaginatedList;
|
||||
@@ -91,7 +91,7 @@ function PublishingInfo({
|
||||
<span>
|
||||
in
|
||||
<strong>
|
||||
{isDraft ? 'Drafts' : <Breadcrumb document={document} onlyText />}
|
||||
<Breadcrumb document={document} onlyText />
|
||||
</strong>
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -62,7 +62,7 @@ class MainSidebar extends React.Component<Props> {
|
||||
if (!user || !team) return null;
|
||||
|
||||
const draftDocumentsCount = documents.drafts.length;
|
||||
const can = policies.abilties(team.id);
|
||||
const can = policies.abilities(team.id);
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
|
||||
@@ -43,7 +43,7 @@ class SettingsSidebar extends React.Component<Props> {
|
||||
const { team } = auth;
|
||||
if (!team) return null;
|
||||
|
||||
const can = policies.abilties(team.id);
|
||||
const can = policies.abilities(team.id);
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
|
||||
@@ -13,11 +13,13 @@ import CollectionLink from './CollectionLink';
|
||||
import Fade from 'components/Fade';
|
||||
|
||||
import CollectionsStore from 'stores/CollectionsStore';
|
||||
import PoliciesStore from 'stores/PoliciesStore';
|
||||
import UiStore from 'stores/UiStore';
|
||||
import DocumentsStore from 'stores/DocumentsStore';
|
||||
|
||||
type Props = {
|
||||
history: RouterHistory,
|
||||
policies: PoliciesStore,
|
||||
collections: CollectionsStore,
|
||||
documents: DocumentsStore,
|
||||
onCreateCollection: () => void,
|
||||
@@ -41,6 +43,9 @@ class Collections extends React.Component<Props> {
|
||||
const { activeCollectionId } = this.props.ui;
|
||||
if (!activeCollectionId) return;
|
||||
|
||||
const can = this.props.policies.abilities(activeCollectionId);
|
||||
if (!can.update) return;
|
||||
|
||||
this.props.history.push(newDocumentUrl(activeCollectionId));
|
||||
}
|
||||
|
||||
@@ -75,6 +80,6 @@ class Collections extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
export default inject('collections', 'ui', 'documents')(
|
||||
export default inject('collections', 'ui', 'documents', 'policies')(
|
||||
withRouter(Collections)
|
||||
);
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { inject } from 'mobx-react';
|
||||
import { find } from 'lodash';
|
||||
import io from 'socket.io-client';
|
||||
import DocumentsStore from 'stores/DocumentsStore';
|
||||
import CollectionsStore from 'stores/CollectionsStore';
|
||||
import MembershipsStore from 'stores/MembershipsStore';
|
||||
import PoliciesStore from 'stores/PoliciesStore';
|
||||
import AuthStore from 'stores/AuthStore';
|
||||
import UiStore from 'stores/UiStore';
|
||||
|
||||
@@ -13,6 +16,8 @@ type Props = {
|
||||
children: React.Node,
|
||||
documents: DocumentsStore,
|
||||
collections: CollectionsStore,
|
||||
memberships: MembershipsStore,
|
||||
policies: PoliciesStore,
|
||||
auth: AuthStore,
|
||||
ui: UiStore,
|
||||
};
|
||||
@@ -27,34 +32,81 @@ class SocketProvider extends React.Component<Props> {
|
||||
path: '/realtime',
|
||||
});
|
||||
|
||||
const { auth, ui, documents, collections } = this.props;
|
||||
const {
|
||||
auth,
|
||||
ui,
|
||||
documents,
|
||||
collections,
|
||||
memberships,
|
||||
policies,
|
||||
} = this.props;
|
||||
if (!auth.token) return;
|
||||
|
||||
this.socket.on('connect', () => {
|
||||
this.socket.emit('authentication', {
|
||||
token: auth.token,
|
||||
});
|
||||
|
||||
this.socket.on('unauthorized', err => {
|
||||
ui.showToast(err.message);
|
||||
throw err;
|
||||
});
|
||||
this.socket.on('entities', event => {
|
||||
if (event.documents) {
|
||||
event.documents.forEach(doc => {
|
||||
if (doc.deletedAt) {
|
||||
documents.remove(doc.id);
|
||||
} else {
|
||||
documents.add(doc);
|
||||
|
||||
this.socket.on('entities', async event => {
|
||||
if (event.documentIds) {
|
||||
for (const documentDescriptor of event.documentIds) {
|
||||
const documentId = documentDescriptor.id;
|
||||
let document = documents.get(documentId) || {};
|
||||
|
||||
if (event.event === 'documents.delete') {
|
||||
documents.remove(documentId);
|
||||
continue;
|
||||
}
|
||||
|
||||
// if we already have the latest version (it was us that performed the change)
|
||||
// the we don't need to update anything either.
|
||||
const { title, updatedAt } = document;
|
||||
if (updatedAt === documentDescriptor.updatedAt) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// otherwise, grab the latest version of the document
|
||||
try {
|
||||
document = await documents.fetch(documentId, {
|
||||
force: true,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.statusCode === 404 || err.statusCode === 403) {
|
||||
documents.remove(documentId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// if the title changed then we need to update the collection also
|
||||
if (title !== document.title) {
|
||||
if (!event.collectionIds) {
|
||||
event.collectionIds = [];
|
||||
}
|
||||
|
||||
const existing = find(event.collectionIds, {
|
||||
id: document.collectionId,
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
event.collectionIds.push({
|
||||
id: document.collectionId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Move this to the document scene once data loading
|
||||
// has been refactored to be friendlier there.
|
||||
if (
|
||||
auth.user &&
|
||||
doc.id === ui.activeDocumentId &&
|
||||
doc.updatedBy.id !== auth.user.id
|
||||
documentId === ui.activeDocumentId &&
|
||||
document.updatedBy.id !== auth.user.id
|
||||
) {
|
||||
ui.showToast(`Document updated by ${doc.updatedBy.name}`, {
|
||||
ui.showToast(`Document updated by ${document.updatedBy.name}`, {
|
||||
timeout: 30 * 1000,
|
||||
action: {
|
||||
text: 'Refresh',
|
||||
@@ -62,26 +114,69 @@ class SocketProvider extends React.Component<Props> {
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
if (event.collections) {
|
||||
event.collections.forEach(collection => {
|
||||
if (collection.deletedAt) {
|
||||
collections.remove(collection.id);
|
||||
documents.removeCollectionDocuments(collection.id);
|
||||
} else {
|
||||
collections.add(collection);
|
||||
|
||||
if (event.collectionIds) {
|
||||
for (const collectionDescriptor of event.collectionIds) {
|
||||
const collectionId = collectionDescriptor.id;
|
||||
const collection = collections.get(collectionId) || {};
|
||||
|
||||
if (event.event === 'collections.delete') {
|
||||
documents.remove(collectionId);
|
||||
continue;
|
||||
}
|
||||
});
|
||||
|
||||
// if we already have the latest version (it was us that performed the change)
|
||||
// the we don't need to update anything either.
|
||||
const { updatedAt } = collection;
|
||||
if (updatedAt === collectionDescriptor.updatedAt) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await collections.fetch(collectionId, { force: true });
|
||||
} catch (err) {
|
||||
if (err.statusCode === 404 || err.statusCode === 403) {
|
||||
collections.remove(collectionId);
|
||||
documents.removeCollectionDocuments(collectionId);
|
||||
memberships.removeCollectionMemberships(collectionId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on('documents.star', event => {
|
||||
documents.starredIds.set(event.documentId, true);
|
||||
});
|
||||
|
||||
this.socket.on('documents.unstar', event => {
|
||||
documents.starredIds.set(event.documentId, false);
|
||||
});
|
||||
|
||||
this.socket.on('collections.add_user', event => {
|
||||
if (auth.user && event.userId === auth.user.id) {
|
||||
collections.fetch(event.collectionId, { force: true });
|
||||
}
|
||||
|
||||
// Document policies might need updating as the permission changes
|
||||
documents.inCollection(event.collectionId).forEach(document => {
|
||||
policies.remove(document.id);
|
||||
});
|
||||
});
|
||||
|
||||
this.socket.on('collections.remove_user', event => {
|
||||
if (auth.user && event.userId === auth.user.id) {
|
||||
collections.remove(event.collectionId);
|
||||
memberships.removeCollectionMemberships(event.collectionId);
|
||||
documents.removeCollectionDocuments(event.collectionId);
|
||||
} else {
|
||||
memberships.remove(`${event.userId}-${event.collectionId}`);
|
||||
}
|
||||
});
|
||||
|
||||
// received a message from the API server that we should request
|
||||
// to join a specific room. Forward that to the ws server.
|
||||
this.socket.on('join', event => {
|
||||
@@ -96,6 +191,10 @@ class SocketProvider extends React.Component<Props> {
|
||||
});
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.socket.disconnect();
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<SocketContext.Provider value={this.socket}>
|
||||
@@ -105,4 +204,11 @@ class SocketProvider extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
export default inject('auth', 'ui', 'documents', 'collections')(SocketProvider);
|
||||
export default inject(
|
||||
'auth',
|
||||
'ui',
|
||||
'documents',
|
||||
'collections',
|
||||
'memberships',
|
||||
'policies'
|
||||
)(SocketProvider);
|
||||
|
||||
@@ -4,9 +4,8 @@ import { observable } from 'mobx';
|
||||
import { inject, observer } from 'mobx-react';
|
||||
import { withRouter, type RouterHistory } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import { MoreIcon } from 'outline-icons';
|
||||
import Modal from 'components/Modal';
|
||||
import CollectionPermissions from 'scenes/CollectionPermissions';
|
||||
import CollectionMembers from 'scenes/CollectionMembers';
|
||||
|
||||
import { newDocumentUrl } from 'utils/routeHelpers';
|
||||
import getDataTransferFiles from 'utils/getDataTransferFiles';
|
||||
@@ -14,12 +13,13 @@ import importFile from 'utils/importFile';
|
||||
import Collection from 'models/Collection';
|
||||
import UiStore from 'stores/UiStore';
|
||||
import DocumentsStore from 'stores/DocumentsStore';
|
||||
import PoliciesStore from 'stores/PoliciesStore';
|
||||
import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu';
|
||||
import NudeButton from 'components/NudeButton';
|
||||
|
||||
type Props = {
|
||||
position?: 'left' | 'right' | 'center',
|
||||
ui: UiStore,
|
||||
policies: PoliciesStore,
|
||||
documents: DocumentsStore,
|
||||
collection: Collection,
|
||||
history: RouterHistory,
|
||||
@@ -30,7 +30,7 @@ type Props = {
|
||||
@observer
|
||||
class CollectionMenu extends React.Component<Props> {
|
||||
file: ?HTMLInputElement;
|
||||
@observable permissionsModalOpen: boolean = false;
|
||||
@observable membersModalOpen: boolean = false;
|
||||
@observable redirectTo: ?string;
|
||||
|
||||
onNewDocument = (ev: SyntheticEvent<>) => {
|
||||
@@ -81,15 +81,16 @@ class CollectionMenu extends React.Component<Props> {
|
||||
|
||||
onPermissions = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
this.permissionsModalOpen = true;
|
||||
this.membersModalOpen = true;
|
||||
};
|
||||
|
||||
handlePermissionsModalClose = () => {
|
||||
this.permissionsModalOpen = false;
|
||||
handleMembersModalClose = () => {
|
||||
this.membersModalOpen = false;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { collection, position, onOpen, onClose } = this.props;
|
||||
const { policies, collection, position, onOpen, onClose } = this.props;
|
||||
const can = policies.abilities(collection.id);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
@@ -100,44 +101,48 @@ class CollectionMenu extends React.Component<Props> {
|
||||
accept="text/markdown, text/plain"
|
||||
/>
|
||||
<Modal
|
||||
title="Collection permissions"
|
||||
onRequestClose={this.handlePermissionsModalClose}
|
||||
isOpen={this.permissionsModalOpen}
|
||||
title="Collection members"
|
||||
onRequestClose={this.handleMembersModalClose}
|
||||
isOpen={this.membersModalOpen}
|
||||
>
|
||||
<CollectionPermissions
|
||||
<CollectionMembers
|
||||
collection={collection}
|
||||
onSubmit={this.handlePermissionsModalClose}
|
||||
onSubmit={this.handleMembersModalClose}
|
||||
onEdit={this.onEdit}
|
||||
/>
|
||||
</Modal>
|
||||
<DropdownMenu
|
||||
label={
|
||||
<NudeButton>
|
||||
<MoreIcon />
|
||||
</NudeButton>
|
||||
}
|
||||
onOpen={onOpen}
|
||||
onClose={onClose}
|
||||
position={position}
|
||||
>
|
||||
<DropdownMenu onOpen={onOpen} onClose={onClose} position={position}>
|
||||
{collection && (
|
||||
<React.Fragment>
|
||||
<DropdownMenuItem onClick={this.onNewDocument}>
|
||||
New document
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={this.onImportDocument}>
|
||||
Import document
|
||||
</DropdownMenuItem>
|
||||
<hr />
|
||||
<DropdownMenuItem onClick={this.onEdit}>Edit…</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={this.onPermissions}>
|
||||
Permissions…
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={this.onExport}>
|
||||
Export…
|
||||
</DropdownMenuItem>
|
||||
{can.update && (
|
||||
<DropdownMenuItem onClick={this.onNewDocument}>
|
||||
New document
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{can.update && (
|
||||
<DropdownMenuItem onClick={this.onImportDocument}>
|
||||
Import document
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{can.update && <hr />}
|
||||
{can.update && (
|
||||
<DropdownMenuItem onClick={this.onEdit}>Edit…</DropdownMenuItem>
|
||||
)}
|
||||
{can.update && (
|
||||
<DropdownMenuItem onClick={this.onPermissions}>
|
||||
Members…
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{can.export && (
|
||||
<DropdownMenuItem onClick={this.onExport}>
|
||||
Export…
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</React.Fragment>
|
||||
)}
|
||||
<DropdownMenuItem onClick={this.onDelete}>Delete…</DropdownMenuItem>
|
||||
{can.delete && (
|
||||
<DropdownMenuItem onClick={this.onDelete}>Delete…</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenu>
|
||||
</React.Fragment>
|
||||
);
|
||||
@@ -151,4 +156,6 @@ const HiddenInput = styled.input`
|
||||
visibility: hidden;
|
||||
`;
|
||||
|
||||
export default inject('ui', 'documents')(withRouter(CollectionMenu));
|
||||
export default inject('ui', 'documents', 'policies')(
|
||||
withRouter(CollectionMenu)
|
||||
);
|
||||
|
||||
@@ -3,12 +3,12 @@ import * as React from 'react';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
import { observable } from 'mobx';
|
||||
import { inject, observer } from 'mobx-react';
|
||||
import { MoreIcon } from 'outline-icons';
|
||||
|
||||
import Document from 'models/Document';
|
||||
import UiStore from 'stores/UiStore';
|
||||
import AuthStore from 'stores/AuthStore';
|
||||
import CollectionStore from 'stores/CollectionsStore';
|
||||
import PoliciesStore from 'stores/PoliciesStore';
|
||||
import {
|
||||
documentMoveUrl,
|
||||
documentEditUrl,
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
newDocumentUrl,
|
||||
} from 'utils/routeHelpers';
|
||||
import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu';
|
||||
import NudeButton from 'components/NudeButton';
|
||||
|
||||
type Props = {
|
||||
ui: UiStore,
|
||||
@@ -24,6 +23,7 @@ type Props = {
|
||||
position?: 'left' | 'right' | 'center',
|
||||
document: Document,
|
||||
collections: CollectionStore,
|
||||
policies: PoliciesStore,
|
||||
className: string,
|
||||
showPrint?: boolean,
|
||||
showToggleEmbeds?: boolean,
|
||||
@@ -111,6 +111,7 @@ class DocumentMenu extends React.Component<Props> {
|
||||
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
|
||||
|
||||
const {
|
||||
policies,
|
||||
document,
|
||||
position,
|
||||
className,
|
||||
@@ -120,110 +121,94 @@ class DocumentMenu extends React.Component<Props> {
|
||||
onOpen,
|
||||
onClose,
|
||||
} = this.props;
|
||||
const canShareDocuments = auth.team && auth.team.sharing;
|
||||
|
||||
if (document.isArchived) {
|
||||
return (
|
||||
<DropdownMenu
|
||||
label={
|
||||
<NudeButton>
|
||||
<MoreIcon />
|
||||
</NudeButton>
|
||||
}
|
||||
className={className}
|
||||
>
|
||||
<DropdownMenuItem onClick={this.handleRestore}>
|
||||
Restore
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={this.handleDelete}>
|
||||
Delete…
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
const can = policies.abilities(document.id);
|
||||
const canShareDocuments = can.share && auth.team && auth.team.sharing;
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
label={
|
||||
<NudeButton>
|
||||
<MoreIcon />
|
||||
</NudeButton>
|
||||
}
|
||||
className={className}
|
||||
position={position}
|
||||
onOpen={onOpen}
|
||||
onClose={onClose}
|
||||
>
|
||||
{!document.isDraft ? (
|
||||
<React.Fragment>
|
||||
{showPin &&
|
||||
(document.pinned ? (
|
||||
{can.unarchive && (
|
||||
<DropdownMenuItem onClick={this.handleRestore}>
|
||||
Restore
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{showPin &&
|
||||
(document.pinned
|
||||
? can.unpin && (
|
||||
<DropdownMenuItem onClick={this.handleUnpin}>
|
||||
Unpin
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
)
|
||||
: can.pin && (
|
||||
<DropdownMenuItem onClick={this.handlePin}>
|
||||
Pin to collection
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{document.isStarred ? (
|
||||
{document.isStarred
|
||||
? can.unstar && (
|
||||
<DropdownMenuItem onClick={this.handleUnstar}>
|
||||
Unstar
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
)
|
||||
: can.star && (
|
||||
<DropdownMenuItem onClick={this.handleStar}>
|
||||
Star
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canShareDocuments && (
|
||||
<DropdownMenuItem
|
||||
onClick={this.handleShareLink}
|
||||
title="Create a public share link"
|
||||
>
|
||||
Share link…
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<hr />
|
||||
<DropdownMenuItem onClick={this.handleDocumentHistory}>
|
||||
Document history
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={this.handleNewChild}
|
||||
title="Create a new child document for the current document"
|
||||
>
|
||||
New child document
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={this.handleEdit}>Edit</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={this.handleDuplicate}>
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={this.handleArchive}>
|
||||
Archive
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={this.handleDelete}>
|
||||
Delete…
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={this.handleMove}>Move…</DropdownMenuItem>
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<React.Fragment>
|
||||
{canShareDocuments && (
|
||||
<DropdownMenuItem
|
||||
onClick={this.handleShareLink}
|
||||
title="Create a public share link"
|
||||
>
|
||||
Share link…
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={this.handleDelete}>
|
||||
Delete…
|
||||
</DropdownMenuItem>
|
||||
</React.Fragment>
|
||||
{canShareDocuments && (
|
||||
<DropdownMenuItem
|
||||
onClick={this.handleShareLink}
|
||||
title="Create a public share link"
|
||||
>
|
||||
Share link…
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<hr />
|
||||
<DropdownMenuItem onClick={this.handleExport}>
|
||||
Download
|
||||
</DropdownMenuItem>
|
||||
{can.read && (
|
||||
<DropdownMenuItem onClick={this.handleDocumentHistory}>
|
||||
Document history
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{can.update && (
|
||||
<DropdownMenuItem
|
||||
onClick={this.handleNewChild}
|
||||
title="Create a new child document for the current document"
|
||||
>
|
||||
New child document
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{can.update && (
|
||||
<DropdownMenuItem onClick={this.handleEdit}>Edit</DropdownMenuItem>
|
||||
)}
|
||||
{can.update && (
|
||||
<DropdownMenuItem onClick={this.handleDuplicate}>
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{can.archive && (
|
||||
<DropdownMenuItem onClick={this.handleArchive}>
|
||||
Archive
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{can.delete && (
|
||||
<DropdownMenuItem onClick={this.handleDelete}>
|
||||
Delete…
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{can.move && (
|
||||
<DropdownMenuItem onClick={this.handleMove}>Move…</DropdownMenuItem>
|
||||
)}
|
||||
<hr />
|
||||
{can.download && (
|
||||
<DropdownMenuItem onClick={this.handleExport}>
|
||||
Download
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{showPrint && (
|
||||
<DropdownMenuItem onClick={window.print}>Print</DropdownMenuItem>
|
||||
)}
|
||||
@@ -232,4 +217,4 @@ class DocumentMenu extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
export default inject('ui', 'auth', 'collections')(DocumentMenu);
|
||||
export default inject('ui', 'auth', 'collections', 'policies')(DocumentMenu);
|
||||
|
||||
@@ -7,12 +7,14 @@ import { PlusIcon, CollectionIcon, PrivateCollectionIcon } from 'outline-icons';
|
||||
|
||||
import { newDocumentUrl } from 'utils/routeHelpers';
|
||||
import CollectionsStore from 'stores/CollectionsStore';
|
||||
import PoliciesStore from 'stores/PoliciesStore';
|
||||
import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu';
|
||||
import Button from 'components/Button';
|
||||
|
||||
type Props = {
|
||||
label?: React.Node,
|
||||
collections: CollectionsStore,
|
||||
policies: PoliciesStore,
|
||||
};
|
||||
|
||||
@observer
|
||||
@@ -38,7 +40,7 @@ class NewDocumentMenu extends React.Component<Props> {
|
||||
render() {
|
||||
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
|
||||
|
||||
const { collections, label, ...rest } = this.props;
|
||||
const { collections, policies, label, ...rest } = this.props;
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
@@ -53,22 +55,27 @@ class NewDocumentMenu extends React.Component<Props> {
|
||||
{...rest}
|
||||
>
|
||||
<DropdownMenuItem disabled>Choose a collection…</DropdownMenuItem>
|
||||
{collections.orderedData.map(collection => (
|
||||
<DropdownMenuItem
|
||||
key={collection.id}
|
||||
onClick={() => this.handleNewDocument(collection.id)}
|
||||
>
|
||||
{collection.private ? (
|
||||
<PrivateCollectionIcon color={collection.color} />
|
||||
) : (
|
||||
<CollectionIcon color={collection.color} />
|
||||
)}{' '}
|
||||
{collection.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{collections.orderedData.map(collection => {
|
||||
const can = policies.abilities(collection.id);
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={collection.id}
|
||||
onClick={() => this.handleNewDocument(collection.id)}
|
||||
disabled={!can.update}
|
||||
>
|
||||
{collection.private ? (
|
||||
<PrivateCollectionIcon color={collection.color} />
|
||||
) : (
|
||||
<CollectionIcon color={collection.color} />
|
||||
)}{' '}
|
||||
{collection.name}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default inject('collections')(NewDocumentMenu);
|
||||
export default inject('collections', 'policies')(NewDocumentMenu);
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
import * as React from 'react';
|
||||
import { withRouter, type RouterHistory } from 'react-router-dom';
|
||||
import { inject } from 'mobx-react';
|
||||
import { MoreIcon } from 'outline-icons';
|
||||
|
||||
import NudeButton from 'components/NudeButton';
|
||||
import CopyToClipboard from 'components/CopyToClipboard';
|
||||
import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu';
|
||||
import { documentHistoryUrl } from 'utils/routeHelpers';
|
||||
@@ -42,16 +40,7 @@ class RevisionMenu extends React.Component<Props> {
|
||||
)}`;
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
label={
|
||||
<NudeButton>
|
||||
<MoreIcon />
|
||||
</NudeButton>
|
||||
}
|
||||
onOpen={onOpen}
|
||||
onClose={onClose}
|
||||
className={className}
|
||||
>
|
||||
<DropdownMenu onOpen={onOpen} onClose={onClose} className={className}>
|
||||
<DropdownMenuItem onClick={this.handleRestore}>
|
||||
Restore version
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -3,9 +3,7 @@ import * as React from 'react';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
import { inject, observer } from 'mobx-react';
|
||||
import { observable } from 'mobx';
|
||||
import { MoreIcon } from 'outline-icons';
|
||||
|
||||
import NudeButton from 'components/NudeButton';
|
||||
import CopyToClipboard from 'components/CopyToClipboard';
|
||||
import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu';
|
||||
import SharesStore from 'stores/SharesStore';
|
||||
@@ -49,15 +47,7 @@ class ShareMenu extends React.Component<Props> {
|
||||
const { share, onOpen, onClose } = this.props;
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
label={
|
||||
<NudeButton>
|
||||
<MoreIcon />
|
||||
</NudeButton>
|
||||
}
|
||||
onOpen={onOpen}
|
||||
onClose={onClose}
|
||||
>
|
||||
<DropdownMenu onOpen={onOpen} onClose={onClose}>
|
||||
<CopyToClipboard text={share.url} onCopy={this.handleCopy}>
|
||||
<DropdownMenuItem>Copy link</DropdownMenuItem>
|
||||
</CopyToClipboard>
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { inject, observer } from 'mobx-react';
|
||||
import { MoreIcon } from 'outline-icons';
|
||||
|
||||
import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu';
|
||||
import NudeButton from 'components/NudeButton';
|
||||
import UsersStore from 'stores/UsersStore';
|
||||
import User from 'models/User';
|
||||
|
||||
@@ -62,13 +60,7 @@ class UserMenu extends React.Component<Props> {
|
||||
const { user } = this.props;
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
label={
|
||||
<NudeButton>
|
||||
<MoreIcon />
|
||||
</NudeButton>
|
||||
}
|
||||
>
|
||||
<DropdownMenu>
|
||||
{!user.isSuspended &&
|
||||
(user.isAdmin ? (
|
||||
<DropdownMenuItem onClick={this.handleDemote}>
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
// @flow
|
||||
import invariant from 'invariant';
|
||||
import { map, without, pick, filter } from 'lodash';
|
||||
import { pick } from 'lodash';
|
||||
import { action, computed, observable } from 'mobx';
|
||||
import BaseModel from 'models/BaseModel';
|
||||
import Document from 'models/Document';
|
||||
import User from 'models/User';
|
||||
import { client } from 'utils/ApiClient';
|
||||
import type { NavigationNode } from 'types';
|
||||
|
||||
export default class Collection extends BaseModel {
|
||||
@observable isSaving: boolean;
|
||||
@observable isLoadingUsers: boolean;
|
||||
@observable userIds: string[] = [];
|
||||
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -48,45 +45,6 @@ export default class Collection extends BaseModel {
|
||||
return results;
|
||||
}
|
||||
|
||||
@computed
|
||||
get users(): User[] {
|
||||
return filter(this.store.rootStore.users.active, user =>
|
||||
this.userIds.includes(user.id)
|
||||
);
|
||||
}
|
||||
|
||||
@action
|
||||
async fetchUsers() {
|
||||
this.isLoadingUsers = true;
|
||||
|
||||
try {
|
||||
const res = await client.post('/collections.users', { id: this.id });
|
||||
invariant(res && res.data, 'User data should be available');
|
||||
this.userIds = map(res.data, user => user.id);
|
||||
res.data.forEach(this.store.rootStore.users.add);
|
||||
} finally {
|
||||
this.isLoadingUsers = false;
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
async addUser(user: User) {
|
||||
await client.post('/collections.add_user', {
|
||||
id: this.id,
|
||||
userId: user.id,
|
||||
});
|
||||
this.userIds = this.userIds.concat(user.id);
|
||||
}
|
||||
|
||||
@action
|
||||
async removeUser(user: User) {
|
||||
await client.post('/collections.remove_user', {
|
||||
id: this.id,
|
||||
userId: user.id,
|
||||
});
|
||||
this.userIds = without(this.userIds, user.id);
|
||||
}
|
||||
|
||||
@action
|
||||
updateDocument(document: Document) {
|
||||
const travelDocuments = (documentList, path) =>
|
||||
|
||||
@@ -102,7 +102,9 @@ export default class Document extends BaseModel {
|
||||
pin = async () => {
|
||||
this.pinned = true;
|
||||
try {
|
||||
await this.store.pin(this);
|
||||
const res = await this.store.pin(this);
|
||||
invariant(res && res.data, 'Data should be available');
|
||||
this.updateFromJson(res.data);
|
||||
} catch (err) {
|
||||
this.pinned = false;
|
||||
throw err;
|
||||
@@ -113,7 +115,9 @@ export default class Document extends BaseModel {
|
||||
unpin = async () => {
|
||||
this.pinned = false;
|
||||
try {
|
||||
await this.store.unpin(this);
|
||||
const res = await this.store.unpin(this);
|
||||
invariant(res && res.data, 'Data should be available');
|
||||
this.updateFromJson(res.data);
|
||||
} catch (err) {
|
||||
this.pinned = true;
|
||||
throw err;
|
||||
@@ -147,7 +151,6 @@ export default class Document extends BaseModel {
|
||||
if (this.isSaving) return this;
|
||||
|
||||
const isCreating = !this.id;
|
||||
const wasDraft = !this.publishedAt;
|
||||
this.isSaving = true;
|
||||
this.updateTitle();
|
||||
|
||||
@@ -170,11 +173,6 @@ export default class Document extends BaseModel {
|
||||
...options,
|
||||
});
|
||||
} finally {
|
||||
if (wasDraft && options.publish) {
|
||||
this.store.rootStore.collections.fetch(this.collectionId, {
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
this.isSaving = false;
|
||||
}
|
||||
};
|
||||
|
||||
22
app/models/Membership.js
Normal file
22
app/models/Membership.js
Normal file
@@ -0,0 +1,22 @@
|
||||
// @flow
|
||||
import { computed } from 'mobx';
|
||||
import BaseModel from './BaseModel';
|
||||
|
||||
class Membership extends BaseModel {
|
||||
id: string;
|
||||
userId: string;
|
||||
collectionId: string;
|
||||
permission: string;
|
||||
|
||||
@computed
|
||||
get isEditor(): boolean {
|
||||
return this.permission === 'read_write';
|
||||
}
|
||||
|
||||
@computed
|
||||
get isMaintainer(): boolean {
|
||||
return this.permission === 'maintainer';
|
||||
}
|
||||
}
|
||||
|
||||
export default Membership;
|
||||
@@ -17,10 +17,12 @@ import RichMarkdownEditor from 'rich-markdown-editor';
|
||||
import { newDocumentUrl, collectionUrl } from 'utils/routeHelpers';
|
||||
import CollectionsStore from 'stores/CollectionsStore';
|
||||
import DocumentsStore from 'stores/DocumentsStore';
|
||||
import PoliciesStore from 'stores/PoliciesStore';
|
||||
import UiStore from 'stores/UiStore';
|
||||
import Collection from 'models/Collection';
|
||||
|
||||
import Search from 'scenes/Search';
|
||||
import CollectionEdit from 'scenes/CollectionEdit';
|
||||
import CollectionMenu from 'menus/CollectionMenu';
|
||||
import Actions, { Action, Separator } from 'components/Actions';
|
||||
import Heading from 'components/Heading';
|
||||
@@ -35,7 +37,7 @@ import Subheading from 'components/Subheading';
|
||||
import PageTitle from 'components/PageTitle';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import Modal from 'components/Modal';
|
||||
import CollectionPermissions from 'scenes/CollectionPermissions';
|
||||
import CollectionMembers from 'scenes/CollectionMembers';
|
||||
import Tabs from 'components/Tabs';
|
||||
import Tab from 'components/Tab';
|
||||
import PaginatedDocumentList from 'components/PaginatedDocumentList';
|
||||
@@ -44,6 +46,7 @@ type Props = {
|
||||
ui: UiStore,
|
||||
documents: DocumentsStore,
|
||||
collections: CollectionsStore,
|
||||
policies: PoliciesStore,
|
||||
match: Object,
|
||||
theme: Object,
|
||||
};
|
||||
@@ -53,6 +56,7 @@ class CollectionScene extends React.Component<Props> {
|
||||
@observable collection: ?Collection;
|
||||
@observable isFetching: boolean = true;
|
||||
@observable permissionsModalOpen: boolean = false;
|
||||
@observable editModalOpen: boolean = false;
|
||||
@observable redirectTo: ?string;
|
||||
|
||||
componentDidMount() {
|
||||
@@ -77,7 +81,7 @@ class CollectionScene extends React.Component<Props> {
|
||||
this.collection = collection;
|
||||
|
||||
await this.props.documents.fetchPinned({
|
||||
collection: id,
|
||||
collectionId: id,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -101,22 +105,36 @@ class CollectionScene extends React.Component<Props> {
|
||||
this.permissionsModalOpen = false;
|
||||
};
|
||||
|
||||
handleEditModalOpen = () => {
|
||||
this.editModalOpen = true;
|
||||
};
|
||||
|
||||
handleEditModalClose = () => {
|
||||
this.editModalOpen = false;
|
||||
};
|
||||
|
||||
renderActions() {
|
||||
const can = this.props.policies.abilities(this.props.match.params.id);
|
||||
|
||||
return (
|
||||
<Actions align="center" justify="flex-end">
|
||||
<Action>
|
||||
<Tooltip
|
||||
tooltip="New document"
|
||||
shortcut="n"
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button onClick={this.onNewDocument} icon={<PlusIcon />}>
|
||||
New doc
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Action>
|
||||
<Separator />
|
||||
{can.update && (
|
||||
<React.Fragment>
|
||||
<Action>
|
||||
<Tooltip
|
||||
tooltip="New document"
|
||||
shortcut="n"
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button onClick={this.onNewDocument} icon={<PlusIcon />}>
|
||||
New doc
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Action>
|
||||
<Separator />
|
||||
</React.Fragment>
|
||||
)}
|
||||
<Action>
|
||||
<CollectionMenu collection={this.collection} />
|
||||
</Action>
|
||||
@@ -155,18 +173,29 @@ class CollectionScene extends React.Component<Props> {
|
||||
</Link>
|
||||
{collection.private && (
|
||||
<Button onClick={this.onPermissions} neutral>
|
||||
Invite people
|
||||
Manage members…
|
||||
</Button>
|
||||
)}
|
||||
</Wrapper>
|
||||
<Modal
|
||||
title="Collection permissions"
|
||||
title="Collection members"
|
||||
onRequestClose={this.handlePermissionsModalClose}
|
||||
isOpen={this.permissionsModalOpen}
|
||||
>
|
||||
<CollectionPermissions
|
||||
<CollectionMembers
|
||||
collection={this.collection}
|
||||
onSubmit={this.handlePermissionsModalClose}
|
||||
onEdit={this.handleEditModalOpen}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
title="Edit collection"
|
||||
onRequestClose={this.handleEditModalClose}
|
||||
isOpen={this.editModalOpen}
|
||||
>
|
||||
<CollectionEdit
|
||||
collection={this.collection}
|
||||
onSubmit={this.handleEditModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
</Centered>
|
||||
@@ -304,6 +333,6 @@ const Wrapper = styled(Flex)`
|
||||
margin: 10px 0;
|
||||
`;
|
||||
|
||||
export default inject('collections', 'documents', 'ui')(
|
||||
export default inject('collections', 'policies', 'documents', 'ui')(
|
||||
withTheme(CollectionScene)
|
||||
);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { observable } from 'mobx';
|
||||
import { inject, observer } from 'mobx-react';
|
||||
import Input from 'components/Input';
|
||||
import InputRich from 'components/InputRich';
|
||||
import Button from 'components/Button';
|
||||
import Switch from 'components/Switch';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import HelpText from 'components/HelpText';
|
||||
import ColorPicker from 'components/ColorPicker';
|
||||
@@ -13,7 +13,6 @@ import Collection from 'models/Collection';
|
||||
import UiStore from 'stores/UiStore';
|
||||
|
||||
type Props = {
|
||||
history: Object,
|
||||
collection: Collection,
|
||||
ui: UiStore,
|
||||
onSubmit: () => void,
|
||||
@@ -25,11 +24,13 @@ class CollectionEdit extends React.Component<Props> {
|
||||
@observable description: string = '';
|
||||
@observable color: string = '#4E5C6E';
|
||||
@observable isSaving: boolean;
|
||||
@observable private: boolean = false;
|
||||
|
||||
componentWillMount() {
|
||||
this.name = this.props.collection.name;
|
||||
this.description = this.props.collection.description;
|
||||
this.color = this.props.collection.color;
|
||||
this.private = this.props.collection.private;
|
||||
}
|
||||
|
||||
handleSubmit = async (ev: SyntheticEvent<*>) => {
|
||||
@@ -41,8 +42,10 @@ class CollectionEdit extends React.Component<Props> {
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
color: this.color,
|
||||
private: this.private,
|
||||
});
|
||||
this.props.onSubmit();
|
||||
this.props.ui.showToast('The collection was updated');
|
||||
} catch (err) {
|
||||
this.props.ui.showToast(err.message);
|
||||
} finally {
|
||||
@@ -62,6 +65,10 @@ class CollectionEdit extends React.Component<Props> {
|
||||
this.color = color;
|
||||
};
|
||||
|
||||
handlePrivateChange = (ev: SyntheticInputEvent<*>) => {
|
||||
this.private = ev.target.checked;
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Flex column>
|
||||
@@ -91,6 +98,15 @@ class CollectionEdit extends React.Component<Props> {
|
||||
minHeight={68}
|
||||
maxHeight={200}
|
||||
/>
|
||||
<Switch
|
||||
id="private"
|
||||
label="Private collection"
|
||||
onChange={this.handlePrivateChange}
|
||||
checked={this.private}
|
||||
/>
|
||||
<HelpText>
|
||||
A private collection will only be visible to invited team members.
|
||||
</HelpText>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={this.isSaving || !this.props.collection.name}
|
||||
@@ -103,4 +119,4 @@ class CollectionEdit extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
export default inject('ui')(withRouter(CollectionEdit));
|
||||
export default inject('ui')(CollectionEdit);
|
||||
|
||||
122
app/scenes/CollectionMembers/AddPeopleToCollection.js
Normal file
122
app/scenes/CollectionMembers/AddPeopleToCollection.js
Normal file
@@ -0,0 +1,122 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { inject, observer } from 'mobx-react';
|
||||
import { observable } from 'mobx';
|
||||
import { debounce } from 'lodash';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import HelpText from 'components/HelpText';
|
||||
import Input from 'components/Input';
|
||||
import Modal from 'components/Modal';
|
||||
import Empty from 'components/Empty';
|
||||
import PaginatedList from 'components/PaginatedList';
|
||||
import Invite from 'scenes/Invite';
|
||||
import Collection from 'models/Collection';
|
||||
import UiStore from 'stores/UiStore';
|
||||
import AuthStore from 'stores/AuthStore';
|
||||
import UsersStore from 'stores/UsersStore';
|
||||
import MembershipsStore from 'stores/MembershipsStore';
|
||||
import MemberListItem from './components/MemberListItem';
|
||||
|
||||
type Props = {
|
||||
ui: UiStore,
|
||||
auth: AuthStore,
|
||||
collection: Collection,
|
||||
memberships: MembershipsStore,
|
||||
users: UsersStore,
|
||||
onSubmit: () => void,
|
||||
};
|
||||
|
||||
@observer
|
||||
class AddPeopleToCollection extends React.Component<Props> {
|
||||
@observable inviteModalOpen: boolean = false;
|
||||
@observable query: string = '';
|
||||
|
||||
handleInviteModalOpen = () => {
|
||||
this.inviteModalOpen = true;
|
||||
};
|
||||
|
||||
handleInviteModalClose = () => {
|
||||
this.inviteModalOpen = false;
|
||||
};
|
||||
|
||||
handleFilter = (ev: SyntheticInputEvent<HTMLInputElement>) => {
|
||||
this.query = ev.target.value;
|
||||
this.debouncedFetch();
|
||||
};
|
||||
|
||||
debouncedFetch = debounce(() => {
|
||||
this.props.users.fetchPage({
|
||||
query: this.query,
|
||||
});
|
||||
}, 250);
|
||||
|
||||
handleAddUser = user => {
|
||||
try {
|
||||
this.props.memberships.create({
|
||||
collectionId: this.props.collection.id,
|
||||
userId: user.id,
|
||||
permission: 'read_write',
|
||||
});
|
||||
this.props.ui.showToast(`${user.name} was added to the collection`);
|
||||
} catch (err) {
|
||||
this.props.ui.showToast('Could not add user');
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { users, collection, auth } = this.props;
|
||||
const { user, team } = auth;
|
||||
if (!user || !team) return null;
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
<HelpText>
|
||||
Need to add someone who’s not yet on the team yet?{' '}
|
||||
<a role="button" onClick={this.handleInviteModalOpen}>
|
||||
Invite people to {team.name}
|
||||
</a>.
|
||||
</HelpText>
|
||||
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search by name…"
|
||||
value={this.query}
|
||||
onChange={this.handleFilter}
|
||||
label="Search people"
|
||||
labelHidden
|
||||
flex
|
||||
/>
|
||||
<PaginatedList
|
||||
empty={
|
||||
this.query ? (
|
||||
<Empty>No people matching your search</Empty>
|
||||
) : (
|
||||
<Empty>No people left to add</Empty>
|
||||
)
|
||||
}
|
||||
items={users.notInCollection(collection.id, this.query)}
|
||||
fetch={this.query ? undefined : users.fetchPage}
|
||||
renderItem={item => (
|
||||
<MemberListItem
|
||||
key={item.id}
|
||||
user={item}
|
||||
onAdd={() => this.handleAddUser(item)}
|
||||
canEdit
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Modal
|
||||
title="Invite people"
|
||||
onRequestClose={this.handleInviteModalClose}
|
||||
isOpen={this.inviteModalOpen}
|
||||
>
|
||||
<Invite onSubmit={this.handleInviteModalClose} />
|
||||
</Modal>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default inject('auth', 'users', 'memberships', 'ui')(
|
||||
AddPeopleToCollection
|
||||
);
|
||||
143
app/scenes/CollectionMembers/CollectionMembers.js
Normal file
143
app/scenes/CollectionMembers/CollectionMembers.js
Normal file
@@ -0,0 +1,143 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { observable } from 'mobx';
|
||||
import { inject, observer } from 'mobx-react';
|
||||
import { PlusIcon } from 'outline-icons';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import HelpText from 'components/HelpText';
|
||||
import Subheading from 'components/Subheading';
|
||||
import Button from 'components/Button';
|
||||
import PaginatedList from 'components/PaginatedList';
|
||||
import Modal from 'components/Modal';
|
||||
import Collection from 'models/Collection';
|
||||
import UiStore from 'stores/UiStore';
|
||||
import AuthStore from 'stores/AuthStore';
|
||||
import MembershipsStore from 'stores/MembershipsStore';
|
||||
import UsersStore from 'stores/UsersStore';
|
||||
import MemberListItem from './components/MemberListItem';
|
||||
import AddPeopleToCollection from './AddPeopleToCollection';
|
||||
|
||||
type Props = {
|
||||
ui: UiStore,
|
||||
auth: AuthStore,
|
||||
collection: Collection,
|
||||
users: UsersStore,
|
||||
memberships: MembershipsStore,
|
||||
onEdit: () => void,
|
||||
};
|
||||
|
||||
@observer
|
||||
class CollectionMembers extends React.Component<Props> {
|
||||
@observable addModalOpen: boolean = false;
|
||||
|
||||
handleAddModalOpen = () => {
|
||||
this.addModalOpen = true;
|
||||
};
|
||||
|
||||
handleAddModalClose = () => {
|
||||
this.addModalOpen = false;
|
||||
};
|
||||
|
||||
handleRemoveUser = user => {
|
||||
try {
|
||||
this.props.memberships.delete({
|
||||
collectionId: this.props.collection.id,
|
||||
userId: user.id,
|
||||
});
|
||||
this.props.ui.showToast(`${user.name} was removed from the collection`);
|
||||
} catch (err) {
|
||||
this.props.ui.showToast('Could not remove user');
|
||||
}
|
||||
};
|
||||
|
||||
handleUpdateUser = (user, permission) => {
|
||||
try {
|
||||
this.props.memberships.create({
|
||||
collectionId: this.props.collection.id,
|
||||
userId: user.id,
|
||||
permission,
|
||||
});
|
||||
this.props.ui.showToast(`${user.name} permissions were updated`);
|
||||
} catch (err) {
|
||||
this.props.ui.showToast('Could not update user');
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { collection, users, memberships, auth } = this.props;
|
||||
const { user } = auth;
|
||||
if (!user) return null;
|
||||
|
||||
const key = memberships.orderedData.map(m => m.permission).join('-');
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
{collection.private ? (
|
||||
<React.Fragment>
|
||||
<HelpText>
|
||||
Choose which team members have access to view and edit documents
|
||||
in the private <strong>{collection.name}</strong> collection. You
|
||||
can make this collection visible to the entire team by{' '}
|
||||
<a role="button" onClick={this.props.onEdit}>
|
||||
changing its visibility
|
||||
</a>.
|
||||
</HelpText>
|
||||
<span>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={this.handleAddModalOpen}
|
||||
icon={<PlusIcon />}
|
||||
neutral
|
||||
>
|
||||
Add people
|
||||
</Button>
|
||||
</span>
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<HelpText>
|
||||
The <strong>{collection.name}</strong> collection is accessible by
|
||||
everyone on the team. If you want to limit who can view the
|
||||
collection,{' '}
|
||||
<a role="button" onClick={this.props.onEdit}>
|
||||
make it private
|
||||
</a>.
|
||||
</HelpText>
|
||||
)}
|
||||
|
||||
<Subheading>Members</Subheading>
|
||||
<PaginatedList
|
||||
key={key}
|
||||
items={
|
||||
collection.private
|
||||
? users.inCollection(collection.id)
|
||||
: users.orderedData
|
||||
}
|
||||
fetch={collection.private ? memberships.fetchPage : users.fetchPage}
|
||||
options={collection.private ? { id: collection.id } : undefined}
|
||||
renderItem={item => (
|
||||
<MemberListItem
|
||||
key={item.id}
|
||||
user={item}
|
||||
membership={memberships.get(`${item.id}-${collection.id}`)}
|
||||
canEdit={collection.private && item.id !== user.id}
|
||||
onRemove={() => this.handleRemoveUser(item)}
|
||||
onUpdate={permission => this.handleUpdateUser(item, permission)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Modal
|
||||
title={`Add people to ${collection.name}`}
|
||||
onRequestClose={this.handleAddModalClose}
|
||||
isOpen={this.addModalOpen}
|
||||
>
|
||||
<AddPeopleToCollection
|
||||
collection={collection}
|
||||
onSubmit={this.handleAddModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default inject('auth', 'users', 'memberships', 'ui')(CollectionMembers);
|
||||
82
app/scenes/CollectionMembers/components/MemberListItem.js
Normal file
82
app/scenes/CollectionMembers/components/MemberListItem.js
Normal file
@@ -0,0 +1,82 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import Avatar from 'components/Avatar';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import Time from 'shared/components/Time';
|
||||
import Badge from 'components/Badge';
|
||||
import Button from 'components/Button';
|
||||
import InputSelect from 'components/InputSelect';
|
||||
import ListItem from 'components/List/Item';
|
||||
import User from 'models/User';
|
||||
import Membership from 'models/Membership';
|
||||
import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu';
|
||||
|
||||
const PERMISSIONS = [
|
||||
{ label: 'Read only', value: 'read' },
|
||||
{ label: 'Read & Edit', value: 'read_write' },
|
||||
];
|
||||
type Props = {
|
||||
user: User,
|
||||
membership?: ?Membership,
|
||||
canEdit: boolean,
|
||||
onAdd?: () => void,
|
||||
onRemove?: () => void,
|
||||
onUpdate?: (permission: string) => void,
|
||||
};
|
||||
|
||||
const MemberListItem = ({
|
||||
user,
|
||||
membership,
|
||||
onRemove,
|
||||
onUpdate,
|
||||
onAdd,
|
||||
canEdit,
|
||||
}: Props) => {
|
||||
return (
|
||||
<ListItem
|
||||
title={user.name}
|
||||
subtitle={
|
||||
<React.Fragment>
|
||||
Joined <Time dateTime={user.createdAt} /> ago
|
||||
{user.isAdmin && <Badge admin={user.isAdmin}>Admin</Badge>}
|
||||
</React.Fragment>
|
||||
}
|
||||
image={<Avatar src={user.avatarUrl} size={32} />}
|
||||
actions={
|
||||
<Flex align="center">
|
||||
{canEdit &&
|
||||
onUpdate && (
|
||||
<Select
|
||||
label="Permissions"
|
||||
options={PERMISSIONS}
|
||||
value={membership ? membership.permission : undefined}
|
||||
onChange={ev => onUpdate(ev.target.value)}
|
||||
labelHidden
|
||||
/>
|
||||
)}
|
||||
|
||||
{canEdit &&
|
||||
onRemove && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuItem onClick={onRemove}>Remove</DropdownMenuItem>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
{canEdit &&
|
||||
onAdd && (
|
||||
<Button onClick={onAdd} neutral>
|
||||
Add
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const Select = styled(InputSelect)`
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
`;
|
||||
|
||||
export default MemberListItem;
|
||||
@@ -1,5 +1,6 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { PlusIcon } from 'outline-icons';
|
||||
import Avatar from 'components/Avatar';
|
||||
import Button from 'components/Button';
|
||||
import ListItem from 'components/List/Item';
|
||||
@@ -7,19 +8,19 @@ import User from 'models/User';
|
||||
|
||||
type Props = {
|
||||
user: User,
|
||||
showAdd: boolean,
|
||||
canEdit: boolean,
|
||||
onAdd: () => void,
|
||||
};
|
||||
|
||||
const UserListItem = ({ user, onAdd, showAdd }: Props) => {
|
||||
const UserListItem = ({ user, onAdd, canEdit }: Props) => {
|
||||
return (
|
||||
<ListItem
|
||||
title={user.name}
|
||||
image={<Avatar src={user.avatarUrl} size={32} />}
|
||||
actions={
|
||||
showAdd ? (
|
||||
<Button type="button" onClick={onAdd} neutral>
|
||||
Invite
|
||||
canEdit ? (
|
||||
<Button type="button" onClick={onAdd} icon={<PlusIcon />} neutral>
|
||||
Add
|
||||
</Button>
|
||||
) : (
|
||||
undefined
|
||||
3
app/scenes/CollectionMembers/index.js
Normal file
3
app/scenes/CollectionMembers/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
// @flow
|
||||
import CollectionMembers from './CollectionMembers';
|
||||
export default CollectionMembers;
|
||||
@@ -1,163 +0,0 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { reject } from 'lodash';
|
||||
import { observable } from 'mobx';
|
||||
import { inject, observer } from 'mobx-react';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import Fade from 'components/Fade';
|
||||
import Input from 'components/Input';
|
||||
import HelpText from 'components/HelpText';
|
||||
import Subheading from 'components/Subheading';
|
||||
import List from 'components/List';
|
||||
import Placeholder from 'components/List/Placeholder';
|
||||
import Switch from 'components/Switch';
|
||||
import UserListItem from './components/UserListItem';
|
||||
import MemberListItem from './components/MemberListItem';
|
||||
import Collection from 'models/Collection';
|
||||
import UsersStore from 'stores/UsersStore';
|
||||
import AuthStore from 'stores/AuthStore';
|
||||
import UiStore from 'stores/UiStore';
|
||||
|
||||
type Props = {
|
||||
users: UsersStore,
|
||||
ui: UiStore,
|
||||
auth: AuthStore,
|
||||
collection: Collection,
|
||||
};
|
||||
|
||||
@observer
|
||||
class CollectionPermissions extends React.Component<Props> {
|
||||
@observable isEdited: boolean = false;
|
||||
@observable isSaving: boolean = false;
|
||||
@observable filter: string;
|
||||
|
||||
componentDidMount() {
|
||||
this.props.users.fetchPage();
|
||||
this.props.collection.fetchUsers();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.isEdited) {
|
||||
this.props.ui.showToast('Permissions updated');
|
||||
}
|
||||
}
|
||||
|
||||
handlePrivateChange = async (ev: SyntheticInputEvent<*>) => {
|
||||
const { collection } = this.props;
|
||||
|
||||
try {
|
||||
this.isEdited = true;
|
||||
collection.private = ev.target.checked;
|
||||
await collection.save();
|
||||
|
||||
if (collection.private) {
|
||||
await collection.fetchUsers();
|
||||
}
|
||||
} catch (err) {
|
||||
collection.private = !ev.target.checked;
|
||||
this.props.ui.showToast('Collection privacy could not be changed');
|
||||
}
|
||||
};
|
||||
|
||||
handleAddUser = user => {
|
||||
try {
|
||||
this.isEdited = true;
|
||||
this.props.collection.addUser(user);
|
||||
} catch (err) {
|
||||
this.props.ui.showToast('Could not add user');
|
||||
}
|
||||
};
|
||||
|
||||
handleRemoveUser = user => {
|
||||
try {
|
||||
this.isEdited = true;
|
||||
this.props.collection.removeUser(user);
|
||||
} catch (err) {
|
||||
this.props.ui.showToast('Could not remove user');
|
||||
}
|
||||
};
|
||||
|
||||
handleFilter = (ev: SyntheticInputEvent<*>) => {
|
||||
this.filter = ev.target.value.toLowerCase();
|
||||
};
|
||||
|
||||
render() {
|
||||
const { collection, users, auth } = this.props;
|
||||
const { user } = auth;
|
||||
if (!user) return null;
|
||||
|
||||
const otherUsers = reject(users.active, user =>
|
||||
collection.userIds.includes(user.id)
|
||||
);
|
||||
const hasOtherUsers = !!otherUsers.length;
|
||||
const isFirstLoadingUsers =
|
||||
collection.isLoadingUsers && !collection.users.length;
|
||||
const filteredUsers = reject(
|
||||
otherUsers,
|
||||
user => this.filter && !user.name.toLowerCase().includes(this.filter)
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
<HelpText>
|
||||
Choose which people on the team have access to read and edit documents
|
||||
in the <strong>{collection.name}</strong> collection. By default
|
||||
collections are visible to all team members.
|
||||
</HelpText>
|
||||
|
||||
<Switch
|
||||
id="private"
|
||||
label="Private collection"
|
||||
onChange={this.handlePrivateChange}
|
||||
checked={collection.private}
|
||||
/>
|
||||
|
||||
{collection.private && (
|
||||
<Fade>
|
||||
<Flex column>
|
||||
<Subheading>Invited ({collection.users.length})</Subheading>
|
||||
<List>
|
||||
{isFirstLoadingUsers ? (
|
||||
<Placeholder />
|
||||
) : (
|
||||
collection.users.map(member => (
|
||||
<MemberListItem
|
||||
key={member.id}
|
||||
user={member}
|
||||
showRemove={user.id !== member.id}
|
||||
onRemove={() => this.handleRemoveUser(member)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</List>
|
||||
|
||||
{hasOtherUsers && (
|
||||
<React.Fragment>
|
||||
<Subheading>Team Members</Subheading>
|
||||
<Input
|
||||
onChange={this.handleFilter}
|
||||
placeholder="Filter…"
|
||||
value={this.filter}
|
||||
type="search"
|
||||
/>
|
||||
<List>
|
||||
{filteredUsers.map(member => (
|
||||
<UserListItem
|
||||
key={member.id}
|
||||
user={member}
|
||||
onAdd={() => this.handleAddUser(member)}
|
||||
showAdd
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</Flex>
|
||||
</Fade>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default inject('auth', 'ui', 'users')(CollectionPermissions);
|
||||
@@ -1,49 +0,0 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { MoreIcon } from 'outline-icons';
|
||||
import Avatar from 'components/Avatar';
|
||||
import HelpText from 'components/HelpText';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import ListItem from 'components/List/Item';
|
||||
import User from 'models/User';
|
||||
import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu';
|
||||
import NudeButton from 'components/NudeButton';
|
||||
|
||||
type Props = {
|
||||
user: User,
|
||||
showRemove: boolean,
|
||||
onRemove: () => void,
|
||||
};
|
||||
|
||||
const MemberListItem = ({ user, onRemove, showRemove }: Props) => {
|
||||
return (
|
||||
<ListItem
|
||||
title={user.name}
|
||||
image={<Avatar src={user.avatarUrl} size={32} />}
|
||||
actions={
|
||||
<Flex align="center">
|
||||
<Permission as="span">Can edit </Permission>
|
||||
{showRemove && (
|
||||
<DropdownMenu
|
||||
label={
|
||||
<NudeButton>
|
||||
<MoreIcon />
|
||||
</NudeButton>
|
||||
}
|
||||
>
|
||||
<DropdownMenuItem onClick={onRemove}>Remove</DropdownMenuItem>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</Flex>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const Permission = styled(HelpText)`
|
||||
text-transform: uppercase;
|
||||
font-size: 11px;
|
||||
`;
|
||||
|
||||
export default MemberListItem;
|
||||
@@ -1,3 +0,0 @@
|
||||
// @flow
|
||||
import CollectionPermissions from './CollectionPermissions';
|
||||
export default CollectionPermissions;
|
||||
@@ -37,6 +37,7 @@ import ErrorOffline from 'scenes/ErrorOffline';
|
||||
import UiStore from 'stores/UiStore';
|
||||
import AuthStore from 'stores/AuthStore';
|
||||
import DocumentsStore from 'stores/DocumentsStore';
|
||||
import PoliciesStore from 'stores/PoliciesStore';
|
||||
import RevisionsStore from 'stores/RevisionsStore';
|
||||
import Document from 'models/Document';
|
||||
import Revision from 'models/Revision';
|
||||
@@ -60,6 +61,7 @@ type Props = {
|
||||
match: Object,
|
||||
history: RouterHistory,
|
||||
location: Location,
|
||||
policies: PoliciesStore,
|
||||
documents: DocumentsStore,
|
||||
revisions: RevisionsStore,
|
||||
auth: AuthStore,
|
||||
@@ -89,6 +91,16 @@ class DocumentScene extends React.Component<Props> {
|
||||
this.loadEditor();
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
if (this.document) {
|
||||
const policy = this.props.policies.get(this.document.id);
|
||||
|
||||
if (!policy) {
|
||||
this.loadDocument(this.props);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
clearTimeout(this.viewTimeout);
|
||||
}
|
||||
@@ -100,18 +112,26 @@ class DocumentScene extends React.Component<Props> {
|
||||
@keydown('m')
|
||||
goToMove(ev) {
|
||||
ev.preventDefault();
|
||||
const document = this.document;
|
||||
if (!document) return;
|
||||
|
||||
if (this.document && !this.document.isArchived && !this.document.isDraft) {
|
||||
this.props.history.push(documentMoveUrl(this.document));
|
||||
const can = this.props.policies.abilities(document.id);
|
||||
|
||||
if (can.update) {
|
||||
this.props.history.push(documentMoveUrl(document));
|
||||
}
|
||||
}
|
||||
|
||||
@keydown('e')
|
||||
goToEdit(ev) {
|
||||
ev.preventDefault();
|
||||
const document = this.document;
|
||||
if (!document) return;
|
||||
|
||||
if (this.document && !this.document.isArchived) {
|
||||
this.props.history.push(documentEditUrl(this.document));
|
||||
const can = this.props.policies.abilities(document.id);
|
||||
|
||||
if (can.update) {
|
||||
this.props.history.push(documentEditUrl(document));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,6 +336,9 @@ class DocumentScene extends React.Component<Props> {
|
||||
|
||||
const embedsDisabled = team && !team.documentEmbeds;
|
||||
|
||||
// this line is only here to make MobX understand that policies are a dependency of this component
|
||||
this.props.policies.abilities(document.id);
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<Container
|
||||
@@ -428,5 +451,5 @@ const Container = styled(Flex)`
|
||||
`;
|
||||
|
||||
export default withRouter(
|
||||
inject('ui', 'auth', 'documents', 'revisions')(DocumentScene)
|
||||
inject('ui', 'auth', 'documents', 'policies', 'revisions')(DocumentScene)
|
||||
);
|
||||
|
||||
@@ -21,11 +21,14 @@ import DocumentShare from 'scenes/DocumentShare';
|
||||
import Button from 'components/Button';
|
||||
import Tooltip from 'components/Tooltip';
|
||||
import Modal from 'components/Modal';
|
||||
import Fade from 'components/Fade';
|
||||
import Badge from 'components/Badge';
|
||||
import Collaborators from 'components/Collaborators';
|
||||
import { Action, Separator } from 'components/Actions';
|
||||
import PoliciesStore from 'stores/PoliciesStore';
|
||||
|
||||
type Props = {
|
||||
policies: PoliciesStore,
|
||||
document: Document,
|
||||
isDraft: boolean,
|
||||
isEditing: boolean,
|
||||
@@ -96,6 +99,7 @@ class Header extends React.Component<Props> {
|
||||
|
||||
const {
|
||||
document,
|
||||
policies,
|
||||
isEditing,
|
||||
isDraft,
|
||||
isPublishing,
|
||||
@@ -104,10 +108,11 @@ class Header extends React.Component<Props> {
|
||||
publishingIsDisabled,
|
||||
auth,
|
||||
} = this.props;
|
||||
const canShareDocuments =
|
||||
auth.team && auth.team.sharing && !document.isArchived;
|
||||
|
||||
const can = policies.abilities(document.id);
|
||||
const canShareDocuments = auth.team && auth.team.sharing && can.share;
|
||||
const canToggleEmbeds = auth.team && auth.team.documentEmbeds;
|
||||
const canEdit = !document.isArchived && !isEditing;
|
||||
const canEdit = can.update && !isEditing;
|
||||
|
||||
return (
|
||||
<Actions
|
||||
@@ -128,9 +133,13 @@ class Header extends React.Component<Props> {
|
||||
/>
|
||||
</Modal>
|
||||
<Breadcrumb document={document} />
|
||||
<Title isHidden={!this.isScrolled} onClick={this.handleClickTitle}>
|
||||
{document.title} {document.isArchived && <Badge>Archived</Badge>}
|
||||
</Title>
|
||||
{this.isScrolled && (
|
||||
<Title onClick={this.handleClickTitle}>
|
||||
<Fade>
|
||||
{document.title} {document.isArchived && <Badge>Archived</Badge>}
|
||||
</Fade>
|
||||
</Title>
|
||||
)}
|
||||
<Wrapper align="center" justify="flex-end">
|
||||
{!isDraft && !isEditing && <Collaborators document={document} />}
|
||||
{isSaving &&
|
||||
@@ -175,18 +184,19 @@ class Header extends React.Component<Props> {
|
||||
</Action>
|
||||
</React.Fragment>
|
||||
)}
|
||||
{isDraft && (
|
||||
<Action>
|
||||
<Button
|
||||
onClick={this.handlePublish}
|
||||
title="Publish document"
|
||||
disabled={publishingIsDisabled}
|
||||
small
|
||||
>
|
||||
{isPublishing ? 'Publishing…' : 'Publish'}
|
||||
</Button>
|
||||
</Action>
|
||||
)}
|
||||
{canEdit &&
|
||||
isDraft && (
|
||||
<Action>
|
||||
<Button
|
||||
onClick={this.handlePublish}
|
||||
title="Publish document"
|
||||
disabled={publishingIsDisabled}
|
||||
small
|
||||
>
|
||||
{isPublishing ? 'Publishing…' : 'Publish'}
|
||||
</Button>
|
||||
</Action>
|
||||
)}
|
||||
{canEdit && (
|
||||
<Action>
|
||||
<Tooltip
|
||||
@@ -252,6 +262,7 @@ const Status = styled.div`
|
||||
const Wrapper = styled(Flex)`
|
||||
width: 100%;
|
||||
align-self: flex-end;
|
||||
height: 32px;
|
||||
|
||||
${breakpoint('tablet')`
|
||||
width: 33.3%;
|
||||
@@ -293,9 +304,6 @@ const Title = styled.div`
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
transition: opacity 100ms ease-in-out;
|
||||
opacity: ${props => (props.isHidden ? '0' : '1')};
|
||||
cursor: ${props => (props.isHidden ? 'default' : 'pointer')};
|
||||
display: none;
|
||||
width: 0;
|
||||
|
||||
@@ -305,4 +313,4 @@ const Title = styled.div`
|
||||
`};
|
||||
`;
|
||||
|
||||
export default inject('auth')(Header);
|
||||
export default inject('auth', 'policies')(Header);
|
||||
|
||||
@@ -37,7 +37,7 @@ class Drafts extends React.Component<Props> {
|
||||
) : (
|
||||
<React.Fragment>
|
||||
<Subheading>Documents</Subheading>
|
||||
<DocumentList documents={drafts} showCollection />
|
||||
<DocumentList documents={drafts} showDraft={false} showCollection />
|
||||
{showLoading && <ListPlaceholder />}
|
||||
</React.Fragment>
|
||||
)}
|
||||
|
||||
@@ -73,6 +73,7 @@ class Invite extends React.Component<Props> {
|
||||
|
||||
handleCopy = () => {
|
||||
this.linkCopied = true;
|
||||
this.props.ui.showToast('A link was copied to your clipboard');
|
||||
};
|
||||
|
||||
render() {
|
||||
|
||||
@@ -145,13 +145,18 @@ export default class BaseStore<T: BaseModel> {
|
||||
|
||||
this.addPolicies(res.policies);
|
||||
return this.add(res.data);
|
||||
} catch (err) {
|
||||
if (err.statusCode === 403) {
|
||||
this.remove(id);
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
this.isFetching = false;
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
async fetchPage(params: ?PaginationParams): Promise<*> {
|
||||
fetchPage = async (params: ?PaginationParams): Promise<*> => {
|
||||
if (!this.actions.includes('list')) {
|
||||
throw new Error(`Cannot list ${this.modelName}`);
|
||||
}
|
||||
@@ -171,7 +176,7 @@ export default class BaseStore<T: BaseModel> {
|
||||
} finally {
|
||||
this.isFetching = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@computed
|
||||
get orderedData(): T[] {
|
||||
|
||||
@@ -173,12 +173,12 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||
try {
|
||||
const res = await client.post(`/documents.${request}`, options);
|
||||
invariant(res && res.data, 'Document list not available');
|
||||
const { data } = res;
|
||||
runInAction('DocumentsStore#fetchNamedPage', () => {
|
||||
data.forEach(this.add);
|
||||
res.data.forEach(this.add);
|
||||
this.addPolicies(res.policies);
|
||||
this.isLoaded = true;
|
||||
});
|
||||
return data;
|
||||
return res.data;
|
||||
} finally {
|
||||
this.isFetching = false;
|
||||
}
|
||||
@@ -313,7 +313,10 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||
|
||||
try {
|
||||
const doc: ?Document = this.data.get(id) || this.getByUrl(id);
|
||||
if (doc) return doc;
|
||||
const policy = doc ? this.rootStore.policies.get(doc.id) : undefined;
|
||||
if (doc && policy && !options.force) {
|
||||
return doc;
|
||||
}
|
||||
|
||||
const res = await client.post('/documents.info', {
|
||||
id,
|
||||
@@ -354,7 +357,7 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||
@action
|
||||
duplicate = async (document: Document): * => {
|
||||
const res = await client.post('/documents.create', {
|
||||
publish: true,
|
||||
publish: !!document.publishedAt,
|
||||
parentDocumentId: document.parentDocumentId,
|
||||
collectionId: document.collectionId,
|
||||
title: `${document.title} (duplicate)`,
|
||||
@@ -425,6 +428,7 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||
runInAction('Document#archive', () => {
|
||||
invariant(res && res.data, 'Data should be available');
|
||||
document.updateFromJson(res.data);
|
||||
this.addPolicies(res.policies);
|
||||
});
|
||||
|
||||
const collection = this.getCollectionForDocument(document);
|
||||
@@ -440,6 +444,7 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||
runInAction('Document#restore', () => {
|
||||
invariant(res && res.data, 'Data should be available');
|
||||
document.updateFromJson(res.data);
|
||||
this.addPolicies(res.policies);
|
||||
});
|
||||
|
||||
const collection = this.getCollectionForDocument(document);
|
||||
|
||||
82
app/stores/MembershipsStore.js
Normal file
82
app/stores/MembershipsStore.js
Normal file
@@ -0,0 +1,82 @@
|
||||
// @flow
|
||||
import invariant from 'invariant';
|
||||
import { action, runInAction } from 'mobx';
|
||||
import { client } from 'utils/ApiClient';
|
||||
import BaseStore from './BaseStore';
|
||||
import RootStore from './RootStore';
|
||||
import Membership from 'models/Membership';
|
||||
import type { PaginationParams } from 'types';
|
||||
|
||||
export default class MembershipsStore extends BaseStore<Membership> {
|
||||
actions = ['create', 'delete'];
|
||||
|
||||
constructor(rootStore: RootStore) {
|
||||
super(rootStore, Membership);
|
||||
}
|
||||
|
||||
@action
|
||||
fetchPage = async (params: ?PaginationParams): Promise<*> => {
|
||||
this.isFetching = true;
|
||||
|
||||
try {
|
||||
const res = await client.post(`/collections.memberships`, params);
|
||||
|
||||
invariant(res && res.data, 'Data not available');
|
||||
|
||||
runInAction(`/collections.memberships`, () => {
|
||||
res.data.users.forEach(this.rootStore.users.add);
|
||||
res.data.memberships.forEach(this.add);
|
||||
this.isLoaded = true;
|
||||
});
|
||||
return res.data.users;
|
||||
} finally {
|
||||
this.isFetching = false;
|
||||
}
|
||||
};
|
||||
|
||||
@action
|
||||
async create({
|
||||
collectionId,
|
||||
userId,
|
||||
permission,
|
||||
}: {
|
||||
collectionId: string,
|
||||
userId: string,
|
||||
permission: string,
|
||||
}) {
|
||||
const res = await client.post('/collections.add_user', {
|
||||
id: collectionId,
|
||||
userId,
|
||||
permission,
|
||||
});
|
||||
invariant(res && res.data, 'Membership data should be available');
|
||||
|
||||
res.data.users.forEach(this.rootStore.users.add);
|
||||
res.data.memberships.forEach(this.add);
|
||||
}
|
||||
|
||||
@action
|
||||
async delete({
|
||||
collectionId,
|
||||
userId,
|
||||
}: {
|
||||
collectionId: string,
|
||||
userId: string,
|
||||
}) {
|
||||
await client.post('/collections.remove_user', {
|
||||
id: collectionId,
|
||||
userId,
|
||||
});
|
||||
|
||||
this.remove(`${userId}-${collectionId}`);
|
||||
}
|
||||
|
||||
@action
|
||||
removeCollectionMemberships = (collectionId: string) => {
|
||||
this.data.forEach((membership, key) => {
|
||||
if (key.includes(collectionId)) {
|
||||
this.remove(key);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -10,7 +10,7 @@ export default class PoliciesStore extends BaseStore<Policy> {
|
||||
super(rootStore, Policy);
|
||||
}
|
||||
|
||||
abilties(id: string) {
|
||||
abilities(id: string) {
|
||||
const policy = this.get(id);
|
||||
return policy ? policy.abilities : {};
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import CollectionsStore from './CollectionsStore';
|
||||
import DocumentsStore from './DocumentsStore';
|
||||
import EventsStore from './EventsStore';
|
||||
import IntegrationsStore from './IntegrationsStore';
|
||||
import MembershipsStore from './MembershipsStore';
|
||||
import NotificationSettingsStore from './NotificationSettingsStore';
|
||||
import PoliciesStore from './PoliciesStore';
|
||||
import RevisionsStore from './RevisionsStore';
|
||||
@@ -20,6 +21,7 @@ export default class RootStore {
|
||||
documents: DocumentsStore;
|
||||
events: EventsStore;
|
||||
integrations: IntegrationsStore;
|
||||
memberships: MembershipsStore;
|
||||
notificationSettings: NotificationSettingsStore;
|
||||
policies: PoliciesStore;
|
||||
revisions: RevisionsStore;
|
||||
@@ -35,6 +37,7 @@ export default class RootStore {
|
||||
this.documents = new DocumentsStore(this);
|
||||
this.events = new EventsStore(this);
|
||||
this.integrations = new IntegrationsStore(this);
|
||||
this.memberships = new MembershipsStore(this);
|
||||
this.notificationSettings = new NotificationSettingsStore(this);
|
||||
this.policies = new PoliciesStore(this);
|
||||
this.revisions = new RevisionsStore(this);
|
||||
@@ -50,6 +53,7 @@ export default class RootStore {
|
||||
this.documents.clear();
|
||||
this.events.clear();
|
||||
this.integrations.clear();
|
||||
this.memberships.clear();
|
||||
this.notificationSettings.clear();
|
||||
this.policies.clear();
|
||||
this.revisions.clear();
|
||||
|
||||
@@ -59,6 +59,35 @@ export default class UsersStore extends BaseStore<User> {
|
||||
return res.data;
|
||||
};
|
||||
|
||||
notInCollection = (collectionId: string, query: string = '') => {
|
||||
const memberships = filter(
|
||||
this.rootStore.memberships.orderedData,
|
||||
member => member.collectionId === collectionId
|
||||
);
|
||||
const userIds = memberships.map(member => member.userId);
|
||||
const users = filter(this.orderedData, user => !userIds.includes(user.id));
|
||||
if (!query) return users;
|
||||
|
||||
return filter(users, user =>
|
||||
user.name.toLowerCase().match(query.toLowerCase())
|
||||
);
|
||||
};
|
||||
|
||||
inCollection = (collectionId: string, query: string) => {
|
||||
const memberships = filter(
|
||||
this.rootStore.memberships.orderedData,
|
||||
member => member.collectionId === collectionId
|
||||
);
|
||||
const userIds = memberships.map(member => member.userId);
|
||||
const users = filter(this.orderedData, user => userIds.includes(user.id));
|
||||
|
||||
if (!query) return users;
|
||||
|
||||
return filter(users, user =>
|
||||
user.name.toLowerCase().match(query.toLowerCase())
|
||||
);
|
||||
};
|
||||
|
||||
actionOnUser = async (action: string, user: User) => {
|
||||
const res = await client.post(`/users.${action}`, {
|
||||
id: user.id,
|
||||
|
||||
Reference in New Issue
Block a user