Compare commits

...

79 Commits

Author SHA1 Message Date
Tom Moor 2d0690697c 0.58.0 2021-08-03 15:17:06 -07:00
Tom Moor 6b551749d4 chore: Remove version- prefix from docker tags 2021-08-03 14:23:14 -07:00
Jack Baron 52fc861bcf feat: Optimize Dockerfile (#2337)
* feat: optimize dockerfile
use new dockerfile syntaxes
leverage multi-stage builds
strip yarn cache from image
use stricter yarn install command
run as a non-root user

* fix: mark yarn-deduplicate as a required dep
`yarn --production` will fail on a clean install otherwise

* fix: add sequelize required files for migrations

* fix: use correct ARG syntax for multistage builds

* revert: mark yarn-deduplicate as a required dep
no longer required as of 0b3adad751
2021-08-03 13:22:41 -07:00
Tom Moor c81c9a9d2d chore: CI Automated Builds (#2409)
closes #2408
2021-08-02 23:35:13 -07:00
Tom Moor 29c742a673 fix: Settings on 'Security' tab not persisting correctly after refactor (#2407)
* fix: Settings on 'Security' tab not persisting correctly after refactor
closes #2406
2021-08-02 13:37:53 -07:00
Tom Moor dd249021e7 fix: GoogleDrive embeds stopped working with new share urls
closes #2405
2021-08-02 11:09:16 -07:00
Tom Moor 21d3b9c7e0 fix: Formatting of welcome docs :rolleyes: 2021-08-01 13:03:21 -07:00
Tom Moor 6665dfff28 Merge branch 'main' of github.com:outline/outline 2021-08-01 12:55:03 -07:00
Tom Moor cdfe3a7fc3 chore: Add new 'getting started' onboarding document (#2391)
Remove support document
Remove confusing images
Added onboarding checklist
2021-08-01 12:54:41 -07:00
Tom Moor 401c91f90b perf: Correctly parallelize count query in users.list 2021-07-30 12:20:19 -04:00
Tom Moor ed5320507d perf: Separate slow joins (#2394) 2021-07-30 08:50:02 -07:00
Translate-O-Tron e34581d25f New Crowdin updates (#2372) 2021-07-30 07:45:58 -07:00
Tom Moor 65a1e2630c perf: Remove no-longer-used 'backup' columns (#2396)
* perf: Remove no-longer-used 'backup' columns

These were added as part of the move to the v2 editor over a year ago incase any text was not correctly converted. After a year of use no cases of failed conversion have occurred that required the use of this column

* Remove migration, will do in 2-step release
2021-07-30 07:22:17 -07:00
Tom Moor 59de4a7db0 feat: Default to "recently viewed" (#2390)
* feat: Default user to first collection on first app open

* Default home tab to 'recently viewed'

* fix: Styling of inactive tab
2021-07-30 07:16:03 -07:00
Tom Moor 63eb8aadaf fix: Flow, remove misused withTranslation on functional component 2021-07-30 00:52:42 -04:00
Saumya Pandey 37fd7ec97a fix: Enable offline access to google accounts (#2392)
* Enable google offline access

* Prevent overriding prompt parameter
2021-07-29 20:04:57 -07:00
Tom Moor 928106067f chore: Tone down notices (#2393) 2021-07-29 20:04:45 -07:00
Tom Moor cb7c27690f fix: Slow tooltips on timestamps 2021-07-28 20:26:04 -04:00
Tom Moor 26da8c4165 feat: Add 'done' icon when all tasks are complete 2021-07-28 19:55:46 -04:00
Tom Moor 36b8ae859e fix: Bump Editor
fix: Sticky formatting toolbar behavior on iOS
fix: Image caption localized
2021-07-28 18:01:01 -04:00
Tom Moor ad1eaa5210 fix: Jank at beginning of loading indicator bar 2021-07-28 17:56:44 -04:00
Saumya Pandey 98024f6be1 fix: "1 tasks done" incorrectly pluralized (#2382) 2021-07-29 01:39:55 +05:30
falleng0d 37c02a572b feat: Auto detect language on login page access (#2338)
* feat: Auto detect language on login page access

* fix: Apply tommoor suggested changes

* fix: QOL improvements for translators

* fix: consistency fix provider -> authProviderName

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-07-28 12:00:02 -07:00
Tom Moor e53bb8bfbc fix: Error uploading fallback avatar when name contains characters that need to be escaped (#2387)
* Todo -> Task to match new langauge elsewhere

* fix: Correctly escape characters in Tiley url

* Move encoding to avatars logic, add test
2021-07-28 11:45:47 -07:00
Tom Moor 2a473bf7b4 Todo -> Task to match new langauge elsewhere 2021-07-28 13:15:30 -04:00
Tom Moor f3b09ab56a test 2021-07-27 21:30:00 -04:00
Tom Moor 6eb51a9cb9 chore: Allow passing of page to revisions backfill script 2021-07-27 18:53:39 -04:00
Tom Moor d01c40badb fix: Minor positioning fix of Account menu 2021-07-27 18:16:23 -04:00
Tom Moor fc551c91bd Bump editor
- Fixes enter with horizontal gap cursor
- Improves pasting behavior
- Fixes heading uncollapse when value changes
- Fixes notice blocks not hidden with other collapsed content
closes #2371
2021-07-27 17:50:16 -04:00
Tom Moor fdc1955b91 fix: Mixture of middots with different weights in document meta 2021-07-27 10:33:26 -04:00
Tom Moor b6703671e2 fix: Task progress svg shrinks width in some circumstances 2021-07-27 10:33:11 -04:00
Tom Moor 84f647674a Merge branch 'main' of github.com:outline/outline 2021-07-27 10:24:36 -04:00
Saumya Pandey a81fbd8608 fix: Show tasks completion on document list items (#2342)
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-07-27 11:31:27 +05:30
Tom Moor 8ee018a759 feat: Web concurrency (#2347)
* feat: Fork multiple processes

* Remove boxen

* comment

* chore: Add support for Heroku DATABASE_CONNECTION_POOL_URL
closes #2306
2021-07-26 15:51:50 -07:00
Tom Moor 6815c940b2 fix: Failure case during account provision that can result in no welcome collection 2021-07-26 13:46:55 -04:00
Saumya Pandey c9bd3bbf45 fix: Editing title in sidebar allows removal of title (#2364) 2021-07-26 00:17:39 +05:30
Translate-O-Tron f61f9703f3 New Crowdin updates (#2368) 2021-07-25 08:23:53 -07:00
Tom Moor 48d538b424 fix: Server error when rendering share for deleted document
closes #2352
2021-07-23 11:25:11 -04:00
Tom Moor 84ad7c482c fix: Various editor header and metadata fixes (#2361)
* fix: Publish button disabled on drafts in read-only mode
fix: Template selector appears on edited documents

* fix: Save button does not immediately come available when selecting a template

* fix: Template menu item alignment
closes #2204

* fixes: Use policy for display of star in document title
closes #2354

* fix: Modified time is sometimes bold when last edited user is current user
closes #2355

* fix: Allow starring of drafts
2021-07-22 15:17:18 -07:00
Tom Moor d35b5d2613 tidy for blog post ;) 2021-07-22 13:43:29 -04:00
Tom Moor 3090c2cfa3 chore: Improve perf of new tab loading by caching team policy in localStorage (#2351) 2021-07-21 15:53:57 -07:00
Translate-O-Tron 140b04c126 New Crowdin updates (#2340) 2021-07-21 15:24:45 -07:00
Tom Moor 2aedf4440b feat: Enable Persian language translations (#2341) 2021-07-21 10:41:45 -07:00
Tom Moor 6e07ee3f3e chore: Move animations and globals from shared directory (#2344) 2021-07-21 10:34:55 -07:00
Saumya Pandey bba8cd183b fix: Archive and trash a document by dropping in the sidebar (#2318) 2021-07-21 00:49:41 +05:30
Saumya Pandey 0bc609634c fix: Allow searching of previous document titles (#2326)
* Add migrations

* Handle previousTitles when titles is updated

* Add necessary test cases

* Use previous title while searching

* Rewrite logic to update previousTitles in beforeSave hook

* Update weights

* Update test to match new rank order

* Add tooltip to inform user on document

* Add code comment

* Remove previous title tooltip

* fix: Remove unused string, add model tests

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-07-20 10:35:29 -07:00
Tom Moor b3b8cb3d9c missing translation string 2021-07-20 12:02:46 -04:00
Saumya Pandey fdb85ec195 fix: Separate toasts storage to own MobX store (#2339)
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-07-20 14:36:10 +05:30
Tom Moor f64ab37d3c fix: Interpolation on archive/delete translationsg 2021-07-19 17:26:48 -04:00
Tom Moor 0b3adad751 chore: Move yarn-deduplicate postinstall -> prepare
should not run in production
2021-07-19 17:12:24 -04:00
Tom Moor 83477de300 fix: Account for revisions.create event being debounced 2021-07-19 17:02:33 -04:00
Tom Moor 1726006858 chore: Pass problematic url to error tracking
towards #2319
2021-07-19 16:57:06 -04:00
Tom Moor 3d9eaeeeeb chore: Add revisions.create backfill script (#2330)
* chore: Add revisions.create backfill script

* fix: Correct timestamp on revisions.create events
2021-07-19 13:32:03 -07:00
falleng0d 2e955353ae feat: translations (#2275)
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-07-19 11:12:53 -07:00
Tom Moor 05aba68457 feat: Add support for collapsible headings (#2327) 2021-07-19 09:19:36 -07:00
Tom Moor 8f6e956bc5 chore: Add documentId index to events table (#2331) 2021-07-19 09:19:26 -07:00
Tom Moor 0cad99c343 chore: Move 'templates' to bottom of sidebar (#2328)
chore: Hide trash and archive for read-only users
2021-07-19 09:18:33 -07:00
Translate-O-Tron 04746f6a2c New Crowdin updates (#2304) 2021-07-16 06:46:32 -07:00
Tom Moor 25907f5c72 chore: Reduce idle CPU usage in development 2021-07-16 09:30:43 -04:00
Jack Baron d7a21db72f fix: Remove duplicate translation key (#2325) 2021-07-16 14:36:34 +05:30
Saumya Pandey 9596979993 fix: Add translation hooks on document and collection pages (#2307) 2021-07-16 01:49:09 +05:30
Tom Moor 31714efb0b feat: useBoolean hook (#2314)
* feat: Add useBoolean hook and example usage

* More example usage

* chore: More useBoolean conversion
2021-07-15 12:27:03 -07:00
Tom Moor 8884da8a4b feat: Add revisionCreator command (#2321)
add revisions.create event
2021-07-15 12:26:43 -07:00
Tom Moor 30cf244610 chore: Loading placeholders (#2322)
* Improve visual of loading mask

* Normalize placeholder naming

* Remove unused file
2021-07-15 12:26:34 -07:00
Saumya Pandey 3f030540b3 fix: Add translation hooks on settings screen (#2298)
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-07-15 14:50:36 +05:30
Saumya Pandey 7ae3addea0 fix: Add space to the valid index characters list (#2316) 2021-07-15 00:35:47 +05:30
Saumya Pandey a9d758bb0c fix: Add translation hooks on remaining files (#2311) 2021-07-15 00:30:08 +05:30
Matheus Breguêz 06e16eef12 feat: Add Google DataStudio embed (#2293)
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-07-14 11:57:12 -07:00
Tom Moor 8e5a2b85c2 feat: Improved UI motion design (#2310)
* feat: Improved UI motion design

* fix: Animation direction when screen placement causes context menu to be flipped
2021-07-12 11:57:17 -07:00
Saumya Pandey 5689d96cc4 fix: Add translation hooks on groups screen (#2303)
* Refactor groups page to functional component and translate strings

* Update app/scenes/GroupNew.js

Co-authored-by: Tom Moor <tom.moor@gmail.com>

* Update app/scenes/GroupEdit.js

Co-authored-by: Tom Moor <tom.moor@gmail.com>

* Update app/scenes/GroupDelete.js

Co-authored-by: Tom Moor <tom.moor@gmail.com>

* Update app/scenes/GroupMembers/GroupMembers.js

Co-authored-by: Tom Moor <tom.moor@gmail.com>

* Format GroupMember.js

* Change Trans usage

* Format GroupDelete

* Revert "Format GroupDelete"

This reverts commit 880128f94d.

* Update app/scenes/GroupNew.js

Co-authored-by: Tom Moor <tom.moor@gmail.com>

* Update app/scenes/GroupNew.js

Co-authored-by: Tom Moor <tom.moor@gmail.com>

* Update GroupNew

* Remove newlines

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-07-12 11:54:55 -07:00
Tom Moor 5cd4dbd9d7 fix: Mispositioned TOC control on mobile due to merge conflict
fix: Show message in mobile TOC when no headings in document
fix: MenuItem with level should still have background edge-to-edge
fix: Show developer warning when creating incorrect menu item type
2021-07-11 13:09:10 -04:00
Tom Moor 587a0e0517 chore: Update html import related deps 2021-07-11 10:02:35 -04:00
Tom Moor 686ecdfa92 fix: CSS syntax error 2021-07-09 14:09:52 -04:00
Translate-O-Tron bb019b081f New Crowdin updates (#2281) 2021-07-09 05:55:06 -07:00
Saumya Pandey 7d5fbeb7b0 fix: Add access to document TOC on mobile (#2279)
* Add TOC button for mobile

* Undo NewDocumentMenu changes

* Place the toc button in the correct position.

* Pass menu props to menuitem

* Update app/menus/TableOfContentsMenu.js

Co-authored-by: Tom Moor <tom.moor@gmail.com>

* Update app/menus/TableOfContentsMenu.js

Co-authored-by: Tom Moor <tom.moor@gmail.com>

* Use the existing prop type

* Write menu inside actions prop

* Prevent blank webpage behaviour for toc

* Use href instead of level to determine target

* Update app/scenes/Document/components/Header.js

Co-authored-by: Tom Moor <tom.moor@gmail.com>

* Add heading to menu items

* Use existing Heading component

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-07-09 04:50:27 -07:00
Tom Moor 056f89fcfd fix: Allow TOC to scroll when larger than browser height (#2296) 2021-07-09 04:07:28 -07:00
Tom Moor 0e7d352781 chore: Add fetch-retry, remove isomorphic-fetch (#2297)
* chore: Add fetch-retry, remove isomorphic-fetch

closes #2270

* test: Mock fetch
2021-07-09 04:07:18 -07:00
Tom Moor b5e4e4fe82 fix: Various mobile fixes (#2295)
* fix: Input placeholder ellipsis

* fix: Hide scrollbar on nav tabs on mobile

* fix: Header actions should be fixed on mobile

* fix: Add fade when content in tabs does not fit in available horizontal width
2021-07-08 18:32:14 -07:00
Tom Moor e41f17c701 feat: Enable Japanese translations (#2282) 2021-07-08 18:32:05 -07:00
186 changed files with 5839 additions and 2605 deletions
+94 -2
View File
@@ -1,4 +1,12 @@
version: 2
version: 2.1
executors:
docker-publisher:
environment:
IMAGE_NAME: outlinewiki/outline
docker:
- image: circleci/buildpack-deps:stretch
jobs:
build:
working_directory: ~/outline
@@ -40,4 +48,88 @@ jobs:
command: yarn test
- run:
name: build-webpack
command: yarn build:webpack
command: yarn build:webpack
build-image:
executor: docker-publisher
steps:
- checkout
- setup_remote_docker:
version: 20.10.6
- run:
name: Build Docker image
command: docker build -t $IMAGE_NAME:latest .
- run:
name: Archive Docker image
command: docker save -o image.tar $IMAGE_NAME
- persist_to_workspace:
root: .
paths:
- ./image.tar
publish-latest:
executor: docker-publisher
steps:
- attach_workspace:
at: /tmp/workspace
- setup_remote_docker:
version: 20.10.6
- run:
name: Load archived Docker image
command: docker load -i /tmp/workspace/image.tar
- run:
name: Publish Docker Image to Docker Hub
command: |
echo "$DOCKERHUB_PASS" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin
IMAGE_TAG=${CIRCLE_TAG/v/''}
docker tag $IMAGE_NAME:latest $IMAGE_NAME:$IMAGE_TAG
docker push $IMAGE_NAME:latest
docker push $IMAGE_NAME:$IMAGE_TAG
publish-tag:
executor: docker-publisher
steps:
- attach_workspace:
at: /tmp/workspace
- setup_remote_docker:
version: 20.10.6
- run:
name: Load archived Docker image
command: docker load -i /tmp/workspace/image.tar
- run:
name: Publish Docker Image to Docker Hub
command: |
echo "$DOCKERHUB_PASS" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin
IMAGE_TAG=${CIRCLE_TAG/v/''}
docker tag $IMAGE_NAME:latest $IMAGE_NAME:$IMAGE_TAG
docker push $IMAGE_NAME:$IMAGE_TAG
workflows:
version: 2
build-and-test:
jobs:
- build:
filters:
tags:
ignore: /^v.*/
build-docker:
jobs:
- build-image:
filters:
tags:
only: /^v.*/
branches:
ignore: /.*/
- publish-latest:
requires:
- build-image
filters:
tags:
only: /^v\d+\.\d+\.\d+$/
branches:
ignore: /.*/
- publish-tag:
requires:
- build-image
filters:
tags:
only: /^v\d+\.\d+\.\d+-.*$/
branches:
ignore: /.*/
+4
View File
@@ -94,6 +94,10 @@ FORCE_HTTPS=true
# the maintainers
ENABLE_UPDATES=true
# How many processes should be spawned. As a reasonable rule divide your servers
# available memory by 512 for a rough estimate
WEB_CONCURRENCY=1
# Override the maxium size of document imports, could be required if you have
# especially large Word documents with embedded imagery
MAXIMUM_IMPORT_SIZE=5120000
+39 -13
View File
@@ -1,23 +1,49 @@
FROM node:14-alpine
# syntax=docker/dockerfile:1.2
ARG APP_PATH=/opt/outline
FROM node:14-alpine AS deps-common
ENV APP_PATH /opt/outline
RUN mkdir -p $APP_PATH
ARG APP_PATH
WORKDIR $APP_PATH
COPY ./package.json ./yarn.lock ./
# ---
FROM deps-common AS deps-dev
RUN yarn install --no-optional --frozen-lockfile && \
yarn cache clean
# ---
FROM deps-common AS deps-prod
RUN yarn install --production=true --frozen-lockfile && \
yarn cache clean
# ---
FROM node:14-alpine AS builder
ARG APP_PATH
WORKDIR $APP_PATH
COPY package.json ./
COPY yarn.lock ./
RUN yarn --pure-lockfile
COPY . .
COPY --from=deps-dev $APP_PATH/node_modules ./node_modules
RUN yarn build
RUN yarn build && \
yarn --production --ignore-scripts --prefer-offline && \
rm -rf shared && \
rm -rf app
# ---
FROM node:14-alpine AS runner
ARG APP_PATH
WORKDIR $APP_PATH
ENV NODE_ENV production
CMD yarn start
COPY --from=builder $APP_PATH/build ./build
COPY --from=builder $APP_PATH/server ./server
COPY --from=builder $APP_PATH/.sequelizerc ./.sequelizerc
COPY --from=deps-prod $APP_PATH/node_modules ./node_modules
COPY --from=builder $APP_PATH/package.json ./package.json
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001 && \
chown -R nodejs:nodejs $APP_PATH/build
USER nodejs
EXPOSE 3000
CMD ["yarn", "start"]
+2 -5
View File
@@ -6,6 +6,7 @@ import { Redirect } from "react-router-dom";
import { isCustomSubdomain } from "shared/utils/domains";
import LoadingIndicator from "components/LoadingIndicator";
import useStores from "../hooks/useStores";
import { changeLanguage } from "../utils/language";
import env from "env";
type Props = {
@@ -20,11 +21,7 @@ const Authenticated = ({ children }: Props) => {
// Watching for language changes here as this is the earliest point we have
// the user available and means we can start loading translations faster
React.useEffect(() => {
if (language && i18n.language !== language) {
// Languages are stored in en_US format in the database, however the
// frontend translation framework (i18next) expects en-US
i18n.changeLanguage(language.replace("_", "-"));
}
changeLanguage(language, i18n);
}, [i18n, language]);
if (auth.authenticated) {
+1 -1
View File
@@ -1,7 +1,7 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import { bounceIn } from "shared/styles/animations";
import { bounceIn } from "styles/animations";
type Props = {|
count: number,
+78
View File
@@ -0,0 +1,78 @@
// @flow
import React from "react";
import styled, { useTheme } from "styled-components";
const cleanPercentage = (percentage) => {
const tooLow = !Number.isFinite(+percentage) || percentage < 0;
const tooHigh = percentage > 100;
return tooLow ? 0 : tooHigh ? 100 : +percentage;
};
const Circle = ({
color,
percentage,
offset,
}: {
color: string,
percentage?: number,
offset: number,
}) => {
const radius = offset * 0.7;
const circumference = 2 * Math.PI * radius;
let strokePercentage;
if (percentage) {
// because the circle is so small, anything greater than 85% appears like 100%
percentage = percentage > 85 && percentage < 100 ? 85 : percentage;
strokePercentage = percentage
? ((100 - percentage) * circumference) / 100
: 0;
}
return (
<circle
r={radius}
cx={offset}
cy={offset}
fill="none"
stroke={strokePercentage !== circumference ? color : ""}
strokeWidth={2.5}
strokeDasharray={circumference}
strokeDashoffset={percentage ? strokePercentage : 0}
strokeLinecap="round"
style={{ transition: "stroke-dashoffset 0.6s ease 0s" }}
></circle>
);
};
const CircularProgressBar = ({
percentage,
size = 16,
}: {
percentage: number,
size?: number,
}) => {
const theme = useTheme();
percentage = cleanPercentage(percentage);
const offset = Math.floor(size / 2);
return (
<SVG width={size} height={size}>
<g transform={`rotate(-90 ${offset} ${offset})`}>
<Circle color={theme.progressBarBackground} offset={offset} />
{percentage > 0 && (
<Circle
color={theme.primary}
percentage={percentage}
offset={offset}
/>
)}
</g>
</SVG>
);
};
const SVG = styled.svg`
flex-shrink: 0;
`;
export default CircularProgressBar;
+4 -2
View File
@@ -12,13 +12,15 @@ import LoadingIndicator from "components/LoadingIndicator";
import NudeButton from "components/NudeButton";
import useDebouncedCallback from "hooks/useDebouncedCallback";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
type Props = {|
collection: Collection,
|};
function CollectionDescription({ collection }: Props) {
const { collections, ui, policies } = useStores();
const { collections, policies } = useStores();
const { showToast } = useToasts();
const { t } = useTranslation();
const [isExpanded, setExpanded] = React.useState(false);
const [isEditing, setEditing] = React.useState(false);
@@ -53,7 +55,7 @@ function CollectionDescription({ collection }: Props) {
});
setDirty(false);
} catch (err) {
ui.showToast(
showToast(
t("Sorry, an error occurred saving the collection", {
type: "error",
})
+2
View File
@@ -15,6 +15,7 @@ type Props = {|
target?: "_blank",
as?: string | React.ComponentType<*>,
hide?: () => void,
level?: number,
|};
const MenuItem = ({
@@ -88,6 +89,7 @@ export const MenuAnchor = styled.a`
margin: 0;
border: 0;
padding: 12px;
padding-left: ${(props) => 12 + props.level * 10}px;
width: 100%;
min-height: 32px;
background: none;
+9 -41
View File
@@ -9,49 +9,11 @@ import {
MenuItem as BaseMenuItem,
} from "reakit/Menu";
import styled from "styled-components";
import Header from "./Header";
import MenuItem, { MenuAnchor } from "./MenuItem";
import Separator from "./Separator";
import ContextMenu from ".";
type TMenuItem =
| {|
title: React.Node,
to: string,
visible?: boolean,
selected?: boolean,
disabled?: boolean,
|}
| {|
title: React.Node,
onClick: (event: SyntheticEvent<>) => void | Promise<void>,
visible?: boolean,
selected?: boolean,
disabled?: boolean,
|}
| {|
title: React.Node,
href: string,
visible?: boolean,
selected?: boolean,
disabled?: boolean,
|}
| {|
title: React.Node,
visible?: boolean,
disabled?: boolean,
style?: Object,
hover?: boolean,
items: TMenuItem[],
|}
| {|
type: "separator",
visible?: boolean,
|}
| {|
type: "heading",
visible?: boolean,
title: React.Node,
|};
import { type MenuItem as TMenuItem } from "types";
type Props = {|
items: TMenuItem[],
@@ -128,7 +90,8 @@ function Template({ items, ...menu }: Props): React.Node {
key={index}
disabled={item.disabled}
selected={item.selected}
target="_blank"
level={item.level}
target={item.href.startsWith("#") ? undefined : "_blank"}
{...menu}
>
{item.title}
@@ -167,6 +130,11 @@ function Template({ items, ...menu }: Props): React.Node {
return <Separator key={index} />;
}
if (item.type === "heading") {
return <Header>{item.title}</Header>;
}
console.warn("Unrecognized menu item", item);
return null;
});
}
+29 -14
View File
@@ -4,16 +4,18 @@ import { Portal } from "react-portal";
import { Menu } from "reakit/Menu";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import usePrevious from "hooks/usePrevious";
import {
fadeIn,
fadeAndScaleIn,
fadeAndSlideIn,
} from "shared/styles/animations";
import usePrevious from "hooks/usePrevious";
fadeAndSlideUp,
fadeAndSlideDown,
mobileContextMenu,
} from "styles/animations";
type Props = {|
"aria-label": string,
visible?: boolean,
placement?: string,
animating?: boolean,
children: React.Node,
onOpen?: () => void,
@@ -44,13 +46,25 @@ export default function ContextMenu({
return (
<>
<Menu hideOnClickOutside preventBodyScroll {...rest}>
{(props) => (
<Position {...props}>
<Background dir="auto">
{rest.visible || rest.animating ? children : null}
</Background>
</Position>
)}
{(props) => {
// kind of hacky, but this is an effective way of telling which way
// the menu will _actually_ be placed when taking into account screen
// positioning.
const topAnchor = props.style.top === "0";
const rightAnchor = props.placement === "bottom-end";
return (
<Position {...props}>
<Background
dir="auto"
topAnchor={topAnchor}
rightAnchor={rightAnchor}
>
{rest.visible || rest.animating ? children : null}
</Background>
</Position>
);
}}
</Menu>
{(rest.visible || rest.animating) && (
<Portal>
@@ -91,7 +105,7 @@ const Position = styled.div`
`;
const Background = styled.div`
animation: ${fadeAndSlideIn} 200ms ease;
animation: ${mobileContextMenu} 200ms ease;
transform-origin: 50% 100%;
max-width: 100%;
background: ${(props) => props.theme.menuBackground};
@@ -109,9 +123,10 @@ const Background = styled.div`
}
${breakpoint("tablet")`
animation: ${fadeAndScaleIn} 200ms ease;
animation: ${(props) =>
props.topAnchor ? fadeAndSlideDown : fadeAndSlideUp} 200ms ease;
transform-origin: ${(props) =>
props.left !== undefined ? "25%" : "75%"} 0;
props.rightAnchor === "bottom-end" ? "75%" : "25%"} 0;
max-width: 276px;
background: ${(props) => props.theme.menuBackground};
box-shadow: ${(props) => props.theme.menuShadow};
@@ -15,7 +15,7 @@ import RevisionsStore from "stores/RevisionsStore";
import Button from "components/Button";
import Flex from "components/Flex";
import { ListPlaceholder } from "components/LoadingPlaceholder";
import PlaceholderList from "components/List/Placeholder";
import Revision from "./components/Revision";
import { documentHistoryUrl, documentUrl } from "utils/routeHelpers";
@@ -120,7 +120,7 @@ class DocumentHistory extends React.Component<Props> {
</Header>
{showLoading ? (
<Loading>
<ListPlaceholder count={5} />
<PlaceholderList count={5} />
</Loading>
) : (
<ArrowKeyNavigation
+4 -3
View File
@@ -15,6 +15,7 @@ import Flex from "components/Flex";
import Highlight from "components/Highlight";
import StarButton, { AnimatedStar } from "components/Star";
import Tooltip from "components/Tooltip";
import useBoolean from "hooks/useBoolean";
import useCurrentTeam from "hooks/useCurrentTeam";
import useCurrentUser from "hooks/useCurrentUser";
import useStores from "hooks/useStores";
@@ -46,7 +47,7 @@ function DocumentListItem(props: Props, ref) {
const { policies } = useStores();
const currentUser = useCurrentUser();
const currentTeam = useCurrentTeam();
const [menuOpen, setMenuOpen] = React.useState(false);
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const {
document,
showNestedDocuments,
@@ -143,8 +144,8 @@ function DocumentListItem(props: Props, ref) {
<DocumentMenu
document={document}
showPin={showPin}
onOpen={() => setMenuOpen(true)}
onClose={() => setMenuOpen(false)}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
modal={false}
/>
</Actions>
+22 -10
View File
@@ -6,8 +6,10 @@ import { Link } from "react-router-dom";
import styled from "styled-components";
import Document from "models/Document";
import DocumentBreadcrumb from "components/DocumentBreadcrumb";
import DocumentTasks from "components/DocumentTasks";
import Flex from "components/Flex";
import Time from "components/Time";
import useCurrentUser from "hooks/useCurrentUser";
import useStores from "hooks/useStores";
const Container = styled(Flex)`
@@ -50,7 +52,9 @@ function DocumentMeta({
...rest
}: Props) {
const { t } = useTranslation();
const { collections, auth } = useStores();
const { collections } = useStores();
const user = useCurrentUser();
const {
modifiedSinceViewed,
updatedAt,
@@ -61,6 +65,8 @@ function DocumentMeta({
deletedAt,
isDraft,
lastViewedAt,
isTasks,
isTemplate,
} = document;
// Prevent meta information from displaying if updatedBy is not available.
@@ -69,6 +75,8 @@ function DocumentMeta({
return null;
}
const collection = collections.get(document.collectionId);
const lastUpdatedByCurrentUser = user.id === updatedBy.id;
let content;
if (deletedAt) {
@@ -103,14 +111,16 @@ function DocumentMeta({
);
} else {
content = (
<Modified highlight={modifiedSinceViewed}>
<Modified highlight={modifiedSinceViewed && !lastUpdatedByCurrentUser}>
{t("updated")} <Time dateTime={updatedAt} addSuffix />
</Modified>
);
}
const collection = collections.get(document.collectionId);
const updatedByMe = auth.user && auth.user.id === updatedBy.id;
const nestedDocumentsCount = collection
? collection.getDocumentChildren(document.id).length
: 0;
const canShowProgressBar = isTasks && !isTemplate;
const timeSinceNow = () => {
if (isDraft || !showLastViewed) {
@@ -131,13 +141,9 @@ function DocumentMeta({
);
};
const nestedDocumentsCount = collection
? collection.getDocumentChildren(document.id).length
: 0;
return (
<Container align="center" rtl={document.dir === "rtl"} {...rest} dir="ltr">
{updatedByMe ? t("You") : updatedBy.name}&nbsp;
{lastUpdatedByCurrentUser ? t("You") : updatedBy.name}&nbsp;
{to ? <Link to={to}>{content}</Link> : content}
{showCollection && collection && (
<span>
@@ -149,11 +155,17 @@ function DocumentMeta({
)}
{showNestedDocuments && nestedDocumentsCount > 0 && (
<span>
&nbsp;&middot; {nestedDocumentsCount}{" "}
&nbsp; {nestedDocumentsCount}{" "}
{t("nested document", { count: nestedDocumentsCount })}
</span>
)}
&nbsp;{timeSinceNow()}
{canShowProgressBar && (
<>
&nbsp;&nbsp;
<DocumentTasks document={document} />
</>
)}
{children}
</Container>
);
+1 -1
View File
@@ -42,7 +42,7 @@ function DocumentMetaWithViews({ to, isDraft, document, ...rest }: Props) {
<PopoverDisclosure {...popover}>
{(props) => (
<>
&nbsp;&middot;&nbsp;
&nbsp;&nbsp;
<a {...props}>
{t("Viewed by")}{" "}
{onlyYou
+59
View File
@@ -0,0 +1,59 @@
// @flow
import { DoneIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled, { useTheme } from "styled-components";
import CircularProgressBar from "components/CircularProgressBar";
import usePrevious from "../hooks/usePrevious";
import Document from "../models/Document";
import { bounceIn } from "styles/animations";
type Props = {|
document: Document,
|};
function getMessage(t, total, completed) {
if (completed === 0) {
return t(`{{ total }} task`, { total, count: total });
} else if (completed === total) {
return t(`{{ completed }} task done`, { completed, count: completed });
} else {
return t(`{{ completed }} of {{ total }} tasks`, {
total,
completed,
});
}
}
function DocumentTasks({ document }: Props) {
const { tasks, tasksPercentage } = document;
const { t } = useTranslation();
const theme = useTheme();
const { completed, total } = tasks;
const done = completed === total;
const previousDone = usePrevious(done);
const message = getMessage(t, total, completed);
return (
<>
{completed === total ? (
<Done
color={theme.primary}
size={20}
$animated={done && previousDone === false}
/>
) : (
<CircularProgressBar percentage={tasksPercentage} />
)}
&nbsp;{message}
</>
);
}
const Done = styled(DoneIcon)`
margin: -1px;
animation: ${(props) => (props.$animated ? bounceIn : "none")} 600ms;
transform-origin: center center;
`;
export default DocumentTasks;
+7 -6
View File
@@ -4,12 +4,13 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { withRouter, type RouterHistory } from "react-router-dom";
import styled, { withTheme } from "styled-components";
import { light } from "shared/styles/theme";
import { light } from "shared/theme";
import UiStore from "stores/UiStore";
import ErrorBoundary from "components/ErrorBoundary";
import Tooltip from "components/Tooltip";
import embeds from "../embeds";
import useMediaQuery from "hooks/useMediaQuery";
import useToasts from "hooks/useToasts";
import { type Theme } from "types";
import { isModKey } from "utils/keyboard";
import { uploadFile } from "utils/uploadFile";
@@ -58,8 +59,9 @@ type PropsWithRef = Props & {
};
function Editor(props: PropsWithRef) {
const { id, ui, shareId, history } = props;
const { id, shareId, history } = props;
const { t } = useTranslation();
const { showToast } = useToasts();
const isPrinting = useMediaQuery("print");
const onUploadImage = React.useCallback(
@@ -106,11 +108,9 @@ function Editor(props: PropsWithRef) {
const onShowToast = React.useCallback(
(message: string) => {
if (ui) {
ui.showToast(message);
}
showToast(message);
},
[ui]
[showToast]
);
const dictionary = React.useMemo(() => {
@@ -148,6 +148,7 @@ function Editor(props: PropsWithRef) {
hr: t("Divider"),
image: t("Image"),
imageUploadError: t("Sorry, an error occurred uploading the image"),
imageCaptionPlaceholder: t("Write a caption"),
info: t("Info"),
infoNotice: t("Info notice"),
link: t("Link"),
+36 -17
View File
@@ -3,6 +3,7 @@ import * as Sentry from "@sentry/react";
import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { withTranslation, type TFunction, Trans } from "react-i18next";
import styled from "styled-components";
import Button from "components/Button";
import CenteredContent from "components/CenteredContent";
@@ -11,10 +12,11 @@ import PageTitle from "components/PageTitle";
import { githubIssuesUrl } from "../../shared/utils/routeHelpers";
import env from "env";
type Props = {
type Props = {|
children: React.Node,
reloadOnChunkMissing?: boolean,
};
t: TFunction,
|};
@observer
class ErrorBoundary extends React.Component<Props> {
@@ -55,6 +57,8 @@ class ErrorBoundary extends React.Component<Props> {
};
render() {
const { t } = this.props;
if (this.error) {
const error = this.error;
const isReported = !!env.SENTRY_DSN && env.DEPLOYMENT === "hosted";
@@ -63,15 +67,21 @@ class ErrorBoundary extends React.Component<Props> {
if (isChunkError) {
return (
<CenteredContent>
<PageTitle title="Module failed to load" />
<h1>Loading Failed</h1>
<PageTitle title={t("Module failed to load")} />
<h1>
<Trans>Loading Failed</Trans>
</h1>
<HelpText>
Sorry, part of the application failed to load. This may be because
it was updated since you opened the tab or because of a failed
network request. Please try reloading.
<Trans>
Sorry, part of the application failed to load. This may be
because it was updated since you opened the tab or because of a
failed network request. Please try reloading.
</Trans>
</HelpText>
<p>
<Button onClick={this.handleReload}>Reload</Button>
<Button onClick={this.handleReload}>
<Trans>Reload</Trans>
</Button>
</p>
</CenteredContent>
);
@@ -79,23 +89,32 @@ class ErrorBoundary extends React.Component<Props> {
return (
<CenteredContent>
<PageTitle title="Something Unexpected Happened" />
<h1>Something Unexpected Happened</h1>
<PageTitle title={t("Something Unexpected Happened")} />
<h1>
<Trans>Something Unexpected Happened</Trans>
</h1>
<HelpText>
Sorry, an unrecoverable error occurred
{isReported && " our engineers have been notified"}. Please try
reloading the page, it may have been a temporary glitch.
<Trans
defaults="Sorry, an unrecoverable error occurred{{notified}}. Please try reloading the page, it may have been a temporary glitch."
values={{
notified: isReported
? ` ${t("our engineers have been notified")}`
: undefined,
}}
/>
</HelpText>
{this.showDetails && <Pre>{error.toString()}</Pre>}
<p>
<Button onClick={this.handleReload}>Reload</Button>{" "}
<Button onClick={this.handleReload}>
<Trans>Reload</Trans>
</Button>{" "}
{this.showDetails ? (
<Button onClick={this.handleReportBug} neutral>
Report a Bug
<Trans>Report a Bug</Trans>
</Button>
) : (
<Button onClick={this.handleShowDetails} neutral>
Show Details
<Trans>Show Detail</Trans>
</Button>
)}
</p>
@@ -114,4 +133,4 @@ const Pre = styled.pre`
white-space: pre-wrap;
`;
export default ErrorBoundary;
export default withTranslation()<ErrorBoundary>(ErrorBoundary);
+1 -1
View File
@@ -1,6 +1,6 @@
// @flow
import styled from "styled-components";
import { fadeIn } from "shared/styles/animations";
import { fadeIn } from "styles/animations";
const Fade = styled.span`
animation: ${fadeIn} ${(props) => props.timing || "250ms"} ease-in-out;
+1 -1
View File
@@ -45,7 +45,7 @@ const Container = styled.div`
align-items: ${({ align }) => align};
justify-content: ${({ justify }) => justify};
flex-shrink: ${({ shrink }) => (shrink ? 1 : "initial")};
gap: ${({ gap }) => `${gap}px` || "initial"};
gap: ${({ gap }) => (gap ? `${gap}px` : "initial")};
min-height: 0;
min-width: 0;
`;
+5 -1
View File
@@ -72,6 +72,10 @@ const Actions = styled(Flex)`
flex-basis: 0;
min-width: auto;
padding-left: 8px;
${breakpoint("tablet")`
position: unset;
`};
`;
const Wrapper = styled(Flex)`
@@ -84,12 +88,12 @@ const Wrapper = styled(Flex)`
transform: translate3d(0, 0, 0);
backdrop-filter: blur(20px);
min-height: 56px;
justify-content: flex-start;
@media print {
display: none;
}
justify-content: flex-start;
${breakpoint("tablet")`
padding: ${(props) => (props.isCompact ? "12px" : `24px 24px 0`)};
justify-content: "center";
+2 -2
View File
@@ -4,10 +4,10 @@ import { transparentize } from "polished";
import * as React from "react";
import { Portal } from "react-portal";
import styled from "styled-components";
import { fadeAndSlideIn } from "shared/styles/animations";
import parseDocumentSlug from "shared/utils/parseDocumentSlug";
import DocumentsStore from "stores/DocumentsStore";
import HoverPreviewDocument from "components/HoverPreviewDocument";
import { fadeAndSlideDown } from "styles/animations";
import { isInternalUrl } from "utils/urls";
const DELAY_OPEN = 300;
@@ -136,7 +136,7 @@ function HoverPreview({ node, ...rest }: Props) {
}
const Animate = styled.div`
animation: ${fadeAndSlideIn} 150ms ease;
animation: ${fadeAndSlideDown} 150ms ease;
@media print {
display: none;
+4
View File
@@ -29,6 +29,10 @@ const RealInput = styled.input`
background: none;
color: ${(props) => props.theme.text};
height: 30px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&:disabled,
&::placeholder {
+38 -38
View File
@@ -1,58 +1,58 @@
// @flow
import { observable } from "mobx";
import { observer, inject } from "mobx-react";
import { observer } from "mobx-react";
import * as React from "react";
import { Trans } from "react-i18next";
import styled, { withTheme } from "styled-components";
import UiStore from "stores/UiStore";
import Editor from "components/Editor";
import HelpText from "components/HelpText";
import { LabelText, Outline } from "components/Input";
import useStores from "hooks/useStores";
type Props = {|
label: string,
minHeight?: number,
maxHeight?: number,
readOnly?: boolean,
ui: UiStore,
|};
@observer
class InputRich extends React.Component<Props> {
@observable editorComponent: React.ComponentType<any>;
@observable focused: boolean = false;
function InputRich({ label, minHeight, maxHeight, ...rest }: Props) {
const [focused, setFocused] = React.useState<boolean>(false);
const { ui } = useStores();
handleBlur = () => {
this.focused = false;
};
const handleBlur = React.useCallback(() => {
setFocused(false);
}, []);
handleFocus = () => {
this.focused = true;
};
const handleFocus = React.useCallback(() => {
setFocused(true);
}, []);
render() {
const { label, minHeight, maxHeight, ui, ...rest } = this.props;
return (
<>
<LabelText>{label}</LabelText>
<StyledOutline
maxHeight={maxHeight}
minHeight={minHeight}
focused={this.focused}
return (
<>
<LabelText>{label}</LabelText>
<StyledOutline
maxHeight={maxHeight}
minHeight={minHeight}
focused={focused}
>
<React.Suspense
fallback={
<HelpText>
<Trans>Loading editor</Trans>
</HelpText>
}
>
<React.Suspense fallback={<HelpText>Loading editor</HelpText>}>
<Editor
onBlur={this.handleBlur}
onFocus={this.handleFocus}
ui={ui}
grow
{...rest}
/>
</React.Suspense>
</StyledOutline>
</>
);
}
<Editor
onBlur={handleBlur}
onFocus={handleFocus}
ui={ui}
grow
{...rest}
/>
</React.Suspense>
</StyledOutline>
</>
);
}
const StyledOutline = styled(Outline)`
@@ -67,4 +67,4 @@ const StyledOutline = styled(Outline)`
}
`;
export default inject("ui")(withTheme(InputRich));
export default observer(withTheme(InputRich));
+6 -5
View File
@@ -4,18 +4,19 @@ import * as React from "react";
import styled from "styled-components";
import Fade from "components/Fade";
import Flex from "components/Flex";
import Mask from "components/Mask";
import PlaceholderText from "components/PlaceholderText";
type Props = {
count?: number,
};
const Placeholder = ({ count }: Props) => {
const ListPlaceHolder = ({ count }: Props) => {
return (
<Fade>
{times(count || 2, (index) => (
<Item key={index} column auto>
<Mask />
<PlaceholderText header delay={0.2 * index} />
<PlaceholderText delay={0.2 * index} />
</Item>
))}
</Fade>
@@ -23,7 +24,7 @@ const Placeholder = ({ count }: Props) => {
};
const Item = styled(Flex)`
padding: 15px 0 16px;
padding: 10px 0;
`;
export default Placeholder;
export default ListPlaceHolder;
@@ -11,16 +11,14 @@ const LoadingIndicatorBar = () => {
};
const loadingFrame = keyframes`
from {margin-left: -100%; z-index:100;}
to {margin-left: 100%; }
from { margin-left: -100%; }
to { margin-left: 100%; }
`;
const Container = styled.div`
position: fixed;
top: 0;
z-index: ${(props) => props.theme.depths.loadingIndicatorBar};
background-color: #03a9f4;
width: 100%;
animation: ${loadingFrame} 4s ease-in-out infinite;
animation-delay: 250ms;
@@ -30,7 +28,7 @@ const Container = styled.div`
const Loader = styled.div`
width: 100%;
height: 2px;
background-color: #03a9f4;
background-color: ${(props) => props.theme.primary};
`;
export default LoadingIndicatorBar;
@@ -1,30 +0,0 @@
// @flow
import { times } from "lodash";
import * as React from "react";
import styled from "styled-components";
import Fade from "components/Fade";
import Flex from "components/Flex";
import Mask from "components/Mask";
type Props = {
count?: number,
};
const ListPlaceHolder = ({ count }: Props) => {
return (
<Fade>
{times(count || 2, (index) => (
<Item key={index} column auto>
<Mask header />
<Mask />
</Item>
))}
</Fade>
);
};
const Item = styled(Flex)`
padding: 10px 0;
`;
export default ListPlaceHolder;
@@ -1,6 +0,0 @@
// @flow
import ListPlaceholder from "./ListPlaceholder";
import LoadingPlaceholder from "./LoadingPlaceholder";
export default LoadingPlaceholder;
export { ListPlaceholder };
+4
View File
@@ -3,9 +3,11 @@ import { format, formatDistanceToNow } from "date-fns";
import {
enUS,
de,
faIR,
fr,
es,
it,
ja,
ko,
ptBR,
pt,
@@ -21,8 +23,10 @@ const locales = {
en_US: enUS,
de_DE: de,
es_ES: es,
fa_IR: faIR,
fr_FR: fr,
it_IT: it,
ja_JP: ja,
ko_KR: ko,
pt_BR: ptBR,
pt_PT: pt,
+1 -1
View File
@@ -7,12 +7,12 @@ import { useTranslation } from "react-i18next";
import { Dialog, DialogBackdrop, useDialogState } from "reakit/Dialog";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { fadeAndScaleIn } from "shared/styles/animations";
import Flex from "components/Flex";
import NudeButton from "components/NudeButton";
import Scrollable from "components/Scrollable";
import usePrevious from "hooks/usePrevious";
import useUnmount from "hooks/useUnmount";
import { fadeAndScaleIn } from "styles/animations";
let openModals = 0;
+21 -20
View File
@@ -1,5 +1,4 @@
// @flow
import { observer } from "mobx-react";
import * as React from "react";
import Document from "models/Document";
import DocumentListItem from "components/DocumentListItem";
@@ -19,24 +18,26 @@ type Props = {|
showTemplate?: boolean,
|};
@observer
class PaginatedDocumentList extends React.Component<Props> {
render() {
const { empty, heading, documents, fetch, options, ...rest } = this.props;
return (
<PaginatedList
items={documents}
empty={empty}
heading={heading}
fetch={fetch}
options={options}
renderItem={(item) => (
<DocumentListItem key={item.id} document={item} {...rest} />
)}
/>
);
}
}
const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
empty,
heading,
documents,
fetch,
options,
...rest
}: Props) {
return (
<PaginatedList
items={documents}
empty={empty}
heading={heading}
fetch={fetch}
options={options}
renderItem={(item) => (
<DocumentListItem key={item.id} document={item} {...rest} />
)}
/>
);
});
export default PaginatedDocumentList;
+2 -2
View File
@@ -7,7 +7,7 @@ import * as React from "react";
import { Waypoint } from "react-waypoint";
import { DEFAULT_PAGINATION_LIMIT } from "stores/BaseStore";
import DelayedMount from "components/DelayedMount";
import { ListPlaceholder } from "components/LoadingPlaceholder";
import PlaceholderList from "components/List/Placeholder";
type Props = {
fetch?: (options: ?Object) => Promise<void>,
@@ -128,7 +128,7 @@ class PaginatedList extends React.Component<Props> {
)}
{showLoading && (
<DelayedMount>
<ListPlaceholder count={5} />
<PlaceholderList count={5} />
</DelayedMount>
)}
</>
@@ -4,18 +4,19 @@ import styled from "styled-components";
import DelayedMount from "components/DelayedMount";
import Fade from "components/Fade";
import Flex from "components/Flex";
import Mask from "components/Mask";
import PlaceholderText from "components/PlaceholderText";
export default function LoadingPlaceholder(props: Object) {
export default function PlaceholderDocument(props: Object) {
return (
<DelayedMount>
<Wrapper>
<Flex column auto {...props}>
<Mask height={34} />
<PlaceholderText height={34} maxWidth={70} />
<PlaceholderText delay={0.2} maxWidth={40} />
<br />
<Mask />
<Mask />
<Mask />
<PlaceholderText delay={0.2} />
<PlaceholderText delay={0.4} />
<PlaceholderText delay={0.6} />
</Flex>
</Wrapper>
</DelayedMount>
@@ -2,44 +2,48 @@
import * as React from "react";
import styled from "styled-components";
import { randomInteger } from "shared/random";
import { pulsate } from "shared/styles/animations";
import Flex from "components/Flex";
import { pulsate } from "styles/animations";
type Props = {|
header?: boolean,
height?: number,
minWidth?: number,
maxWidth?: number,
delay?: number,
|};
class Mask extends React.Component<Props> {
width: number;
class PlaceholderText extends React.Component<Props> {
width = randomInteger(this.props.minWidth || 75, this.props.maxWidth || 100);
shouldComponentUpdate() {
return false;
}
constructor(props: Props) {
super();
this.width = randomInteger(props.minWidth || 75, props.maxWidth || 100);
}
render() {
return <Redacted width={this.width} height={this.props.height} />;
return (
<Mask
width={this.width}
height={this.props.height}
delay={this.props.delay}
/>
);
}
}
const Redacted = styled(Flex)`
const Mask = styled(Flex)`
width: ${(props) => (props.header ? props.width / 2 : props.width)}%;
height: ${(props) =>
props.height ? props.height : props.header ? 24 : 18}px;
margin-bottom: 6px;
border-radius: 6px;
background-color: ${(props) => props.theme.divider};
animation: ${pulsate} 1.3s infinite;
animation: ${pulsate} 2s infinite;
animation-delay: ${(props) => props.delay || 0}s;
&:last-child {
margin-bottom: 0;
}
`;
export default Mask;
export default PlaceholderText;
+1 -1
View File
@@ -2,7 +2,7 @@
import * as React from "react";
import { Popover as ReakitPopover } from "reakit/Popover";
import styled from "styled-components";
import { fadeAndScaleIn } from "shared/styles/animations";
import { fadeAndScaleIn } from "styles/animations";
type Props = {
children: React.Node,
+26 -41
View File
@@ -1,15 +1,13 @@
// @flow
import { observer } from "mobx-react";
import {
ArchiveIcon,
HomeIcon,
EditIcon,
SearchIcon,
StarredIcon,
ShapesIcon,
TrashIcon,
HomeIcon,
PlusIcon,
SearchIcon,
SettingsIcon,
ShapesIcon,
StarredIcon,
} from "outline-icons";
import * as React from "react";
import { DndProvider } from "react-dnd";
@@ -23,16 +21,22 @@ import Flex from "components/Flex";
import Modal from "components/Modal";
import Scrollable from "components/Scrollable";
import Sidebar from "./Sidebar";
import ArchiveLink from "./components/ArchiveLink";
import Collections from "./components/Collections";
import Section from "./components/Section";
import SidebarLink from "./components/SidebarLink";
import TeamButton from "./components/TeamButton";
import TrashLink from "./components/TrashLink";
import useCurrentTeam from "hooks/useCurrentTeam";
import useCurrentUser from "hooks/useCurrentUser";
import useStores from "hooks/useStores";
import AccountMenu from "menus/AccountMenu";
function MainSidebar() {
const { t } = useTranslation();
const { policies, auth, documents } = useStores();
const { policies, documents } = useStores();
const team = useCurrentTeam();
const user = useCurrentUser();
const [inviteModalOpen, setInviteModalOpen] = React.useState(false);
const [
createCollectionModalOpen,
@@ -71,9 +75,6 @@ function MainSidebar() {
dndArea,
]);
const { user, team } = auth;
if (!user || !team) return null;
const can = policies.abilities(team.id);
return (
@@ -114,17 +115,6 @@ function MainSidebar() {
exact={false}
label={t("Starred")}
/>
{can.createDocument && (
<SidebarLink
to="/templates"
icon={<ShapesIcon color="currentColor" />}
exact={false}
label={t("Templates")}
active={
documents.active ? documents.active.template : undefined
}
/>
)}
{can.createDocument && (
<SidebarLink
to="/drafts"
@@ -151,26 +141,21 @@ function MainSidebar() {
/>
</Section>
<Section>
<SidebarLink
to="/archive"
icon={<ArchiveIcon color="currentColor" />}
exact={false}
label={t("Archive")}
active={
documents.active
? documents.active.isArchived && !documents.active.isDeleted
: undefined
}
/>
<SidebarLink
to="/trash"
icon={<TrashIcon color="currentColor" />}
exact={false}
label={t("Trash")}
active={
documents.active ? documents.active.isDeleted : undefined
}
/>
{can.createDocument && (
<>
<SidebarLink
to="/templates"
icon={<ShapesIcon color="currentColor" />}
exact={false}
label={t("Templates")}
active={
documents.active ? documents.active.template : undefined
}
/>
<ArchiveLink documents={documents} />
<TrashLink documents={documents} />
</>
)}
<SidebarLink
to="/settings"
icon={<SettingsIcon color="currentColor" />}
+1 -1
View File
@@ -6,13 +6,13 @@ import { Portal } from "react-portal";
import { useLocation } from "react-router-dom";
import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { fadeIn } from "shared/styles/animations";
import Fade from "components/Fade";
import Flex from "components/Flex";
import ResizeBorder from "./components/ResizeBorder";
import Toggle, { ToggleButton, Positioner } from "./components/Toggle";
import usePrevious from "hooks/usePrevious";
import useStores from "hooks/useStores";
import { fadeIn } from "styles/animations";
let ANIMATION_MS = 250;
let isFirstRender = true;
@@ -0,0 +1,43 @@
// @flow
import { observer } from "mobx-react";
import { ArchiveIcon } from "outline-icons";
import * as React from "react";
import { useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
import useStores from "../../../hooks/useStores";
import SidebarLink from "./SidebarLink";
import useToasts from "hooks/useToasts";
function ArchiveLink({ documents }) {
const { policies } = useStores();
const { t } = useTranslation();
const { showToast } = useToasts();
const [{ isDocumentDropping }, dropToArchiveDocument] = useDrop({
accept: "document",
drop: async (item, monitor) => {
const document = documents.get(item.id);
await document.archive();
showToast(t("Document archived"), { type: "success" });
},
canDrop: (item, monitor) => policies.abilities(item.id).archive,
collect: (monitor) => ({
isDocumentDropping: monitor.isOver(),
}),
});
return (
<div ref={dropToArchiveDocument}>
<SidebarLink
to="/archive"
icon={<ArchiveIcon color="currentColor" open={isDocumentDropping} />}
exact={false}
label={t("Archive")}
active={documents.active?.isArchived && !documents.active?.isDeleted}
isActiveDrop={isDocumentDropping}
/>
</div>
);
}
export default observer(ArchiveLink);
@@ -12,6 +12,7 @@ import DropCursor from "./DropCursor";
import DropToImport from "./DropToImport";
import EditableTitle from "./EditableTitle";
import SidebarLink from "./SidebarLink";
import useBoolean from "hooks/useBoolean";
import useStores from "hooks/useStores";
import CollectionMenu from "menus/CollectionMenu";
import CollectionSortMenu from "menus/CollectionSortMenu";
@@ -35,7 +36,7 @@ function CollectionLink({
isDraggingAnyCollection,
onChangeDragging,
}: Props) {
const [menuOpen, setMenuOpen] = React.useState(false);
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const handleTitleChange = React.useCallback(
async (name: string) => {
@@ -163,14 +164,14 @@ function CollectionLink({
{can.update && (
<CollectionSortMenuWithMargin
collection={collection}
onOpen={() => setMenuOpen(true)}
onClose={() => setMenuOpen(false)}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
)}
<CollectionMenu
collection={collection}
onOpen={() => setMenuOpen(true)}
onClose={() => setMenuOpen(false)}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
</>
}
@@ -9,11 +9,13 @@ import Fade from "components/Fade";
import Flex from "components/Flex";
import useStores from "../../../hooks/useStores";
import CollectionLink from "./CollectionLink";
import CollectionsLoading from "./CollectionsLoading";
import DropCursor from "./DropCursor";
import Header from "./Header";
import PlaceholderCollections from "./PlaceholderCollections";
import SidebarLink from "./SidebarLink";
import useCurrentTeam from "hooks/useCurrentTeam";
import useToasts from "hooks/useToasts";
type Props = {
onCreateCollection: () => void,
};
@@ -22,6 +24,7 @@ function Collections({ onCreateCollection }: Props) {
const [isFetching, setFetching] = React.useState(false);
const [fetchError, setFetchError] = React.useState();
const { ui, policies, documents, collections } = useStores();
const { showToast } = useToasts();
const isPreloaded: boolean = !!collections.orderedData.length;
const { t } = useTranslation();
const team = useCurrentTeam();
@@ -38,7 +41,7 @@ function Collections({ onCreateCollection }: Props) {
setFetching(true);
await collections.fetchPage({ limit: 100 });
} catch (error) {
ui.showToast(
showToast(
t("Collections could not be loaded, please reload the app"),
{
type: "error",
@@ -51,7 +54,7 @@ function Collections({ onCreateCollection }: Props) {
}
}
load();
}, [collections, isFetching, ui, fetchError, t]);
}, [collections, isFetching, showToast, fetchError, t]);
const [{ isCollectionDropping }, dropToReorderCollection] = useDrop({
accept: "collection",
@@ -105,7 +108,7 @@ function Collections({ onCreateCollection }: Props) {
return (
<Flex column>
<Header>{t("Collections")}</Header>
<CollectionsLoading />
<PlaceholderCollections />
</Flex>
);
}
@@ -1,21 +0,0 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import Mask from "components/Mask";
function CollectionsLoading() {
return (
<Wrapper>
<Mask />
<Mask />
<Mask />
</Wrapper>
);
}
const Wrapper = styled.div`
margin: 4px 16px;
width: 75%;
`;
export default CollectionsLoading;
@@ -12,6 +12,7 @@ import DropCursor from "./DropCursor";
import DropToImport from "./DropToImport";
import EditableTitle from "./EditableTitle";
import SidebarLink from "./SidebarLink";
import useBoolean from "hooks/useBoolean";
import useStores from "hooks/useStores";
import DocumentMenu from "menus/DocumentMenu";
import { type NavigationNode } from "types";
@@ -120,7 +121,7 @@ function DocumentLink(
[documents, document]
);
const [menuOpen, setMenuOpen] = React.useState(false);
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const isMoving = documents.movingDocumentId === node.id;
const manualSort = collection?.sort.field === "index";
@@ -132,7 +133,11 @@ function DocumentLink(
isDragging: !!monitor.isDragging(),
}),
canDrag: (monitor) => {
return policies.abilities(node.id).move;
return (
policies.abilities(node.id).move ||
policies.abilities(node.id).archive ||
policies.abilities(node.id).delete
);
},
});
@@ -245,8 +250,8 @@ function DocumentLink(
<Fade>
<DocumentMenu
document={document}
onOpen={() => setMenuOpen(true)}
onClose={() => setMenuOpen(false)}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
</Fade>
) : undefined
@@ -7,6 +7,7 @@ import styled, { css } from "styled-components";
import LoadingIndicator from "components/LoadingIndicator";
import useImportDocument from "hooks/useImportDocument";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
type Props = {|
children: React.Node,
@@ -18,7 +19,8 @@ type Props = {|
function DropToImport({ disabled, children, collectionId, documentId }: Props) {
const { t } = useTranslation();
const { ui, documents, policies } = useStores();
const { documents, policies } = useStores();
const { showToast } = useToasts();
const { handleFiles, isImporting } = useImportDocument(
collectionId,
documentId
@@ -27,11 +29,11 @@ function DropToImport({ disabled, children, collectionId, documentId }: Props) {
const can = policies.abilities(collectionId);
const handleRejection = React.useCallback(() => {
ui.showToast(
showToast(
t("Document not supported try Markdown, Plain text, HTML, or Word"),
{ type: "error" }
);
}, [t, ui]);
}, [t, showToast]);
if (disabled || !can.update) {
return children;
@@ -1,7 +1,7 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
type Props = {|
onSubmit: (title: string) => Promise<void>,
@@ -13,7 +13,7 @@ function EditableTitle({ title, onSubmit, canUpdate }: Props) {
const [isEditing, setIsEditing] = React.useState(false);
const [originalValue, setOriginalValue] = React.useState(title);
const [value, setValue] = React.useState(title);
const { ui } = useStores();
const { showToast } = useToasts();
React.useEffect(() => {
setValue(title);
@@ -39,26 +39,33 @@ function EditableTitle({ title, onSubmit, canUpdate }: Props) {
[originalValue]
);
const handleSave = React.useCallback(async () => {
setIsEditing(false);
const handleSave = React.useCallback(
async (ev) => {
ev.preventDefault();
if (value === originalValue) {
return;
}
setIsEditing(false);
if (document) {
try {
await onSubmit(value);
setOriginalValue(value);
} catch (error) {
const trimmedValue = value.trim();
if (trimmedValue === originalValue || trimmedValue.length === 0) {
setValue(originalValue);
ui.showToast(error.message, {
type: "error",
});
throw error;
return;
}
}
}, [ui, originalValue, value, onSubmit]);
if (document) {
try {
await onSubmit(trimmedValue);
setOriginalValue(trimmedValue);
} catch (error) {
setValue(originalValue);
showToast(error.message, {
type: "error",
});
throw error;
}
}
},
[originalValue, showToast, value, onSubmit]
);
return (
<>
@@ -0,0 +1,21 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import PlaceholderText from "components/PlaceholderText";
function PlaceholderCollections() {
return (
<Wrapper>
<PlaceholderText />
<PlaceholderText delay={0.2} />
<PlaceholderText delay={0.4} />
</Wrapper>
);
}
const Wrapper = styled.div`
margin: 4px 16px;
width: 75%;
`;
export default PlaceholderCollections;
@@ -0,0 +1,62 @@
// @flow
import { observer } from "mobx-react";
import { TrashIcon } from "outline-icons";
import * as React from "react";
import { useState } from "react";
import { useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
import DocumentDelete from "scenes/DocumentDelete";
import Modal from "components/Modal";
import useStores from "../../../hooks/useStores";
import SidebarLink from "./SidebarLink";
function TrashLink({ documents }) {
const { policies } = useStores();
const { t } = useTranslation();
const [document, setDocument] = useState();
const [{ isDocumentDropping }, dropToTrashDocument] = useDrop({
accept: "document",
drop: (item, monitor) => {
const doc = documents.get(item.id);
// without setTimeout it was not working in firefox v89.0.2-ubuntu
// on dropping mouseup is considered as clicking outside the modal, and it immediately closes
setTimeout(() => setDocument(doc), 1);
},
canDrop: (item, monitor) => policies.abilities(item.id).delete,
collect: (monitor) => ({
isDocumentDropping: monitor.isOver(),
}),
});
return (
<>
<div ref={dropToTrashDocument}>
<SidebarLink
to="/trash"
icon={<TrashIcon color="currentColor" open={isDocumentDropping} />}
exact={false}
label={t("Trash")}
active={documents.active?.isDeleted}
isActiveDrop={isDocumentDropping}
/>
</div>
{document && (
<Modal
title={t("Delete {{ documentName }}", {
documentName: document.noun,
})}
onRequestClose={() => setDocument(undefined)}
isOpen
>
<DocumentDelete
document={document}
onSubmit={() => setDocument(undefined)}
/>
</Modal>
)}
</>
);
}
export default observer(TrashLink);
+5 -5
View File
@@ -11,7 +11,7 @@ import DocumentsStore from "stores/DocumentsStore";
import GroupsStore from "stores/GroupsStore";
import MembershipsStore from "stores/MembershipsStore";
import PoliciesStore from "stores/PoliciesStore";
import UiStore from "stores/UiStore";
import ToastsStore from "stores/ToastsStore";
import ViewsStore from "stores/ViewsStore";
import { getVisibilityListener, getPageVisible } from "utils/pageVisibility";
@@ -27,7 +27,7 @@ type Props = {
policies: PoliciesStore,
views: ViewsStore,
auth: AuthStore,
ui: UiStore,
toasts: ToastsStore,
};
@observer
@@ -72,7 +72,7 @@ class SocketProvider extends React.Component<Props> {
const {
auth,
ui,
toasts,
documents,
collections,
groups,
@@ -113,7 +113,7 @@ class SocketProvider extends React.Component<Props> {
this.socket.on("unauthorized", (err) => {
this.socket.authenticated = false;
ui.showToast(err.message, {
toasts.showToast(err.message, {
type: "error",
});
throw err;
@@ -338,7 +338,7 @@ class SocketProvider extends React.Component<Props> {
export default inject(
"auth",
"ui",
"toasts",
"documents",
"collections",
"groups",
+49 -8
View File
@@ -1,14 +1,26 @@
// @flow
import { m } from "framer-motion";
import * as React from "react";
import { NavLink } from "react-router-dom";
import { NavLink, Route } from "react-router-dom";
import styled, { withTheme } from "styled-components";
import { type Theme } from "types";
type Props = {
theme: Theme,
children: React.Node,
};
const TabLink = styled(NavLink)`
const NavLinkWithChildrenFunc = ({ to, exact = false, children, ...rest }) => (
<Route path={to} exact={exact}>
{({ match }) => (
<NavLink to={to} exact={exact} {...rest}>
{children(match)}
</NavLink>
)}
</Route>
);
const TabLink = styled(NavLinkWithChildrenFunc)`
position: relative;
display: inline-flex;
align-items: center;
@@ -20,19 +32,48 @@ const TabLink = styled(NavLink)`
&:hover {
color: ${(props) => props.theme.textSecondary};
border-bottom: 3px solid ${(props) => props.theme.divider};
padding-bottom: 5px;
}
`;
function Tab({ theme, ...rest }: Props) {
const Active = styled(m.div)`
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 3px;
width: 100%;
border-top-left-radius: 2px;
border-top-right-radius: 2px;
background: ${(props) => props.theme.textSecondary};
`;
const transition = {
type: "spring",
stiffness: 500,
damping: 30,
};
function Tab({ theme, children, ...rest }: Props) {
const activeStyle = {
paddingBottom: "5px",
borderBottom: `3px solid ${theme.textSecondary}`,
color: theme.textSecondary,
};
return <TabLink {...rest} activeStyle={activeStyle} />;
return (
<TabLink {...rest} activeStyle={activeStyle}>
{(match) => (
<>
{children}
{match && (
<Active
layoutId="underline"
initial={false}
transition={transition}
/>
)}
</>
)}
</TabLink>
);
}
export default withTheme(Tab);
+2 -2
View File
@@ -8,7 +8,7 @@ import styled from "styled-components";
import Button from "components/Button";
import Empty from "components/Empty";
import Flex from "components/Flex";
import Mask from "components/Mask";
import PlaceholderText from "components/PlaceholderText";
export type Props = {|
data: any[],
@@ -170,7 +170,7 @@ export const Placeholder = ({
<Row key={row}>
{new Array(columns).fill().map((_, col) => (
<Cell key={col}>
<Mask minWidth={25} maxWidth={75} />
<PlaceholderText minWidth={25} maxWidth={75} />
</Cell>
))}
</Row>
+57 -5
View File
@@ -1,13 +1,40 @@
// @flow
import { AnimateSharedLayout } from "framer-motion";
import { transparentize } from "polished";
import * as React from "react";
import styled from "styled-components";
import useWindowSize from "hooks/useWindowSize";
const Nav = styled.nav`
border-bottom: 1px solid ${(props) => props.theme.divider};
margin: 12px 0;
overflow-y: auto;
white-space: nowrap;
transition: opacity 250ms ease-out;
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
&:after {
content: "";
position: absolute;
top: 0;
right: 0;
width: 50px;
height: 100%;
pointer-events: none;
background: ${(props) =>
props.$shadowVisible
? `linear-gradient(
90deg,
${transparentize(1, props.theme.background)} 0%,
${props.theme.background} 100%
)`
: `transparent`};
}
`;
// When sticky we need extra background coverage around the sides otherwise
@@ -30,11 +57,36 @@ export const Separator = styled.span`
margin-top: 6px;
`;
const Tabs = (props: {}) => {
const Tabs = ({ children }: {| children: React.Node |}) => {
const ref = React.useRef<?HTMLDivElement>();
const [shadowVisible, setShadow] = React.useState(false);
const { width } = useWindowSize();
const updateShadows = React.useCallback(() => {
const c = ref.current;
if (!c) return;
const scrollLeft = c.scrollLeft;
const wrapperWidth = c.scrollWidth - c.clientWidth;
const fade = !!(wrapperWidth - scrollLeft !== 0);
if (fade !== shadowVisible) {
setShadow(fade);
}
}, [shadowVisible]);
React.useEffect(() => {
updateShadows();
}, [width, updateShadows]);
return (
<Sticky>
<Nav {...props}></Nav>
</Sticky>
<AnimateSharedLayout>
<Sticky>
<Nav ref={ref} onScroll={updateShadows} $shadowVisible={shadowVisible}>
{children}
</Nav>
</Sticky>
</AnimateSharedLayout>
);
};
+2 -2
View File
@@ -2,10 +2,10 @@
import { observer } from "mobx-react";
import * as React from "react";
import { ThemeProvider } from "styled-components";
import GlobalStyles from "shared/styles/globals";
import { dark, light, lightMobile, darkMobile } from "shared/styles/theme";
import { dark, light, lightMobile, darkMobile } from "shared/theme";
import useMediaQuery from "hooks/useMediaQuery";
import useStores from "hooks/useStores";
import GlobalStyles from "styles/globals";
const empty = {};
+1 -1
View File
@@ -32,7 +32,7 @@ function Time(props: Props) {
<time dateTime={props.dateTime}>{props.children || content}</time>
}
>
<LocaleTime {...props} />
<LocaleTime tooltipDelay={250} {...props} />
</React.Suspense>
);
}
+1 -1
View File
@@ -3,7 +3,7 @@ import { CheckboxIcon, InfoIcon, WarningIcon } from "outline-icons";
import { darken } from "polished";
import * as React from "react";
import styled, { css } from "styled-components";
import { fadeAndScaleIn, pulse } from "shared/styles/animations";
import { fadeAndScaleIn, pulse } from "styles/animations";
import type { Toast as TToast } from "types";
type Props = {
+3 -3
View File
@@ -6,15 +6,15 @@ import Toast from "components/Toast";
import useStores from "hooks/useStores";
function Toasts() {
const { ui } = useStores();
const { toasts } = useStores();
return (
<List>
{ui.orderedToasts.map((toast) => (
{toasts.orderedData.map((toast) => (
<Toast
key={toast.id}
toast={toast}
onRequestClose={() => ui.removeToast(toast.id)}
onRequestClose={() => toasts.hideToast(toast.id)}
/>
))}
</List>
+39
View File
@@ -0,0 +1,39 @@
// @flow
import * as React from "react";
import Image from "components/Image";
import Frame from "./components/Frame";
const URL_REGEX = new RegExp(
"^https?://datastudio.google.com/(embed|u/0)/reporting/(.*)/page/(.*)(/edit)?$"
);
type Props = {|
attrs: {|
href: string,
matches: string[],
|},
|};
export default class GoogleDataStudio extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
return (
<Frame
{...this.props}
src={this.props.attrs.href.replace("u/0", "embed").replace("/edit", "")}
icon={
<Image
src="/images/google-datastudio.png"
alt="Google Data Studio Icon"
width={16}
height={16}
/>
}
canonicalUrl={this.props.attrs.href}
title="Google Data Studio"
border
/>
);
}
}
+19
View File
@@ -0,0 +1,19 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import GoogleDataStudio from "./GoogleDataStudio";
describe("GoogleDataStudio", () => {
const match = GoogleDataStudio.ENABLED[0];
test("to be enabled on share link", () => {
expect(
"https://datastudio.google.com/embed/reporting/aab01789-f3a2-4ff3-9cba-c4c94c4a92e8/page/7zFD".match(
match
)
).toBeTruthy();
});
test("to not be enabled elsewhere", () => {
expect("https://datastudio.google.com/u/0/".match(match)).toBe(null);
expect("https://datastudio.google.com".match(match)).toBe(null);
expect("https://www.google.com".match(match)).toBe(null);
});
});
+2 -2
View File
@@ -4,7 +4,7 @@ import Image from "components/Image";
import Frame from "./components/Frame";
const URL_REGEX = new RegExp(
"^https?://drive.google.com/file/d/(.*)/(preview|view).?usp=sharing$"
"^https?://drive.google.com/file/d/(.*)/(preview|view).?usp=sharing(.*)"
);
type Props = {|
@@ -29,7 +29,7 @@ export default class GoogleDrive extends React.Component<Props> {
height={16}
/>
}
title="Google Drive Embed"
title="Google Drive"
canonicalUrl={this.props.attrs.href}
border
/>
+6
View File
@@ -3,6 +3,7 @@ import GoogleDrive from "./GoogleDrive";
describe("GoogleDrive", () => {
const match = GoogleDrive.ENABLED[0];
test("to be enabled on share link", () => {
expect(
"https://drive.google.com/file/d/1ohkOgmE8MiNx68u6ynBfYkgjeKu_x3ZK/view?usp=sharing".match(
@@ -14,6 +15,11 @@ describe("GoogleDrive", () => {
match
)
).toBeTruthy();
expect(
"https://drive.google.com/file/d/1ohkOgmE8MiNx68u6ynBfYkgjeKu_x3ZK/preview?usp=sharing&resourceKey=BG8k4dEt1p2gisnVdlaSpA".match(
match
)
).toBeTruthy();
});
test("to not be enabled elsewhere", () => {
+8
View File
@@ -11,6 +11,7 @@ import Descript from "./Descript";
import Figma from "./Figma";
import Framer from "./Framer";
import Gist from "./Gist";
import GoogleDataStudio from "./GoogleDataStudio";
import GoogleDocs from "./GoogleDocs";
import GoogleDrawings from "./GoogleDrawings";
import GoogleDrive from "./GoogleDrive";
@@ -148,6 +149,13 @@ export default [
component: GoogleSlides,
matcher: matcher(GoogleSlides),
},
{
title: "Google Data Studio",
keywords: "business intelligence",
icon: () => <Img src="/images/google-datastudio.png" />,
component: GoogleDataStudio,
matcher: matcher(GoogleDataStudio),
},
{
title: "InVision",
keywords: "design prototype",
+23
View File
@@ -0,0 +1,23 @@
// @flow
import * as React from "react";
type InitialState = boolean | (() => boolean);
/**
* React hook to manage booleans
*
* @param initialState the initial boolean state value
*/
export default function useBoolean(initialState: InitialState = false) {
const [value, setValue] = React.useState(initialState);
const setTrue = React.useCallback(() => {
setValue(true);
}, []);
const setFalse = React.useCallback(() => {
setValue(false);
}, []);
return [value, setTrue, setFalse];
}
+5 -3
View File
@@ -4,6 +4,7 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
let importingLock = false;
@@ -11,7 +12,8 @@ export default function useImportDocument(
collectionId: string,
documentId?: string
): {| handleFiles: (files: File[]) => Promise<void>, isImporting: boolean |} {
const { documents, ui } = useStores();
const { documents } = useStores();
const { showToast } = useToasts();
const [isImporting, setImporting] = React.useState(false);
const { t } = useTranslation();
const history = useHistory();
@@ -51,7 +53,7 @@ export default function useImportDocument(
}
}
} catch (err) {
ui.showToast(`${t("Could not import file")}. ${err.message}`, {
showToast(`${t("Could not import file")}. ${err.message}`, {
type: "error",
});
} finally {
@@ -59,7 +61,7 @@ export default function useImportDocument(
importingLock = false;
}
},
[t, ui, documents, history, collectionId, documentId]
[t, documents, history, showToast, collectionId, documentId]
);
return {
+8
View File
@@ -0,0 +1,8 @@
// @flow
import useStores from "./useStores";
export default function useToasts() {
const { toasts } = useStores();
return { showToast: toasts.showToast, hideToast: toasts.hideToast };
}
+16 -9
View File
@@ -1,5 +1,6 @@
// @flow
import "focus-visible";
import { LazyMotion } from "framer-motion";
import { createBrowserHistory } from "history";
import { Provider } from "mobx-react";
import * as React from "react";
@@ -49,6 +50,10 @@ if ("serviceWorker" in window.navigator) {
});
}
// Make sure to return the specific export containing the feature bundle.
const loadFeatures = () =>
import("./utils/motion.js").then((res) => res.default);
if (element) {
const App = () => (
<React.StrictMode>
@@ -56,15 +61,17 @@ if (element) {
<Analytics>
<Theme>
<ErrorBoundary>
<Router history={history}>
<>
<PageTheme />
<ScrollToTop>
<Routes />
</ScrollToTop>
<Toasts />
</>
</Router>
<LazyMotion features={loadFeatures}>
<Router history={history}>
<>
<PageTheme />
<ScrollToTop>
<Routes />
</ScrollToTop>
<Toasts />
</>
</Router>
</LazyMotion>
</ErrorBoundary>
</Theme>
</Analytics>
+9 -5
View File
@@ -19,6 +19,7 @@ import MenuItem, { MenuAnchor } from "components/ContextMenu/MenuItem";
import Separator from "components/ContextMenu/Separator";
import Flex from "components/Flex";
import Guide from "components/Guide";
import useBoolean from "hooks/useBoolean";
import usePrevious from "hooks/usePrevious";
import useStores from "hooks/useStores";
@@ -72,15 +73,18 @@ const AppearanceMenu = React.forwardRef((props, ref) => {
function AccountMenu(props: Props) {
const menu = useMenuState({
unstable_offset: [8, 0],
placement: "bottom-start",
modal: true,
});
const { auth, ui } = useStores();
const previousTheme = usePrevious(ui.theme);
const { t } = useTranslation();
const [keyboardShortcutsOpen, setKeyboardShortcutsOpen] = React.useState(
false
);
const [
keyboardShortcutsOpen,
handleKeyboardShortcutsOpen,
handleKeyboardShortcutsClose,
] = useBoolean();
React.useEffect(() => {
if (ui.theme !== previousTheme) {
@@ -92,7 +96,7 @@ function AccountMenu(props: Props) {
<>
<Guide
isOpen={keyboardShortcutsOpen}
onRequestClose={() => setKeyboardShortcutsOpen(false)}
onRequestClose={handleKeyboardShortcutsClose}
title={t("Keyboard shortcuts")}
>
<KeyboardShortcuts />
@@ -102,7 +106,7 @@ function AccountMenu(props: Props) {
<MenuItem {...menu} as={Link} to={settings()}>
{t("Settings")}
</MenuItem>
<MenuItem {...menu} onClick={() => setKeyboardShortcutsOpen(true)}>
<MenuItem {...menu} onClick={handleKeyboardShortcutsOpen}>
{t("Keyboard shortcuts")}
</MenuItem>
<MenuItem {...menu} href={developers()} target="_blank">
+5 -3
View File
@@ -15,6 +15,7 @@ import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
import Template, { filterTemplateItems } from "components/ContextMenu/Template";
import Modal from "components/Modal";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
import getDataTransferFiles from "utils/getDataTransferFiles";
import { newDocumentUrl } from "utils/routeHelpers";
@@ -37,7 +38,8 @@ function CollectionMenu({
}: Props) {
const menu = useMenuState({ modal, placement });
const [renderModals, setRenderModals] = React.useState(false);
const { ui, documents, policies } = useStores();
const { documents, policies } = useStores();
const { showToast } = useToasts();
const { t } = useTranslation();
const history = useHistory();
@@ -99,14 +101,14 @@ function CollectionMenu({
});
history.push(document.url);
} catch (err) {
ui.showToast(err.message, {
showToast(err.message, {
type: "error",
});
throw err;
}
},
[history, ui, collection.id, documents]
[history, showToast, collection.id, documents]
);
const can = policies.abilities(collection.id);
+13 -11
View File
@@ -18,6 +18,7 @@ import Template from "components/ContextMenu/Template";
import Flex from "components/Flex";
import Modal from "components/Modal";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
import getDataTransferFiles from "utils/getDataTransferFiles";
import {
documentHistoryUrl,
@@ -51,7 +52,8 @@ function DocumentMenu({
onOpen,
onClose,
}: Props) {
const { policies, collections, ui, documents } = useStores();
const { policies, collections, documents } = useStores();
const { showToast } = useToasts();
const menu = useMenuState({
modal,
unstable_preventOverflow: true,
@@ -83,33 +85,33 @@ function DocumentMenu({
// when duplicating, go straight to the duplicated document content
history.push(duped.url);
ui.showToast(t("Document duplicated"), { type: "success" });
showToast(t("Document duplicated"), { type: "success" });
},
[ui, t, history, document]
[t, history, showToast, document]
);
const handleArchive = React.useCallback(
async (ev: SyntheticEvent<>) => {
await document.archive();
ui.showToast(t("Document archived"), { type: "success" });
showToast(t("Document archived"), { type: "success" });
},
[ui, t, document]
[showToast, t, document]
);
const handleRestore = React.useCallback(
async (ev: SyntheticEvent<>, options?: { collectionId: string }) => {
await document.restore(options);
ui.showToast(t("Document restored"), { type: "success" });
showToast(t("Document restored"), { type: "success" });
},
[ui, t, document]
[showToast, t, document]
);
const handleUnpublish = React.useCallback(
async (ev: SyntheticEvent<>) => {
await document.unpublish();
ui.showToast(t("Document unpublished"), { type: "success" });
showToast(t("Document unpublished"), { type: "success" });
},
[ui, t, document]
[showToast, t, document]
);
const handlePrint = React.useCallback((ev: SyntheticEvent<>) => {
@@ -181,14 +183,14 @@ function DocumentMenu({
);
history.push(importedDocument.url);
} catch (err) {
ui.showToast(err.message, {
showToast(err.message, {
type: "error",
});
throw err;
}
},
[history, ui, collection, documents, document.id]
[history, showToast, collection, documents, document.id]
);
return (
+6 -6
View File
@@ -11,7 +11,7 @@ import MenuItem from "components/ContextMenu/MenuItem";
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
import Separator from "components/ContextMenu/Separator";
import CopyToClipboard from "components/CopyToClipboard";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
import { documentHistoryUrl } from "utils/routeHelpers";
type Props = {|
@@ -22,7 +22,7 @@ type Props = {|
|};
function RevisionMenu({ document, revision, className, iconColor }: Props) {
const { ui } = useStores();
const { showToast } = useToasts();
const menu = useMenuState({ modal: true });
const { t } = useTranslation();
const history = useHistory();
@@ -31,15 +31,15 @@ function RevisionMenu({ document, revision, className, iconColor }: Props) {
async (ev: SyntheticEvent<>) => {
ev.preventDefault();
await document.restore({ revisionId: revision.id });
ui.showToast(t("Document restored"), { type: "success" });
showToast(t("Document restored"), { type: "success" });
history.push(document.url);
},
[history, ui, t, document, revision]
[history, showToast, t, document, revision]
);
const handleCopy = React.useCallback(() => {
ui.showToast(t("Link copied"), { type: "info" });
}, [ui, t]);
showToast(t("Link copied"), { type: "info" });
}, [showToast, t]);
const url = `${window.location.origin}${documentHistoryUrl(
document,
+8 -6
View File
@@ -10,6 +10,7 @@ import MenuItem from "components/ContextMenu/MenuItem";
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
import CopyToClipboard from "components/CopyToClipboard";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
type Props = {
share: Share,
@@ -17,7 +18,8 @@ type Props = {
function ShareMenu({ share }: Props) {
const menu = useMenuState({ modal: true });
const { ui, shares, policies } = useStores();
const { shares, policies } = useStores();
const { showToast } = useToasts();
const { t } = useTranslation();
const history = useHistory();
const can = policies.abilities(share.id);
@@ -36,17 +38,17 @@ function ShareMenu({ share }: Props) {
try {
await shares.revoke(share);
ui.showToast(t("Share link revoked"), { type: "info" });
showToast(t("Share link revoked"), { type: "info" });
} catch (err) {
ui.showToast(err.message, { type: "error" });
showToast(err.message, { type: "error" });
}
},
[t, shares, share, ui]
[t, shares, share, showToast]
);
const handleCopy = React.useCallback(() => {
ui.showToast(t("Share link copied"), { type: "info" });
}, [t, ui]);
showToast(t("Share link copied"), { type: "info" });
}, [t, showToast]);
return (
<>
+76
View File
@@ -0,0 +1,76 @@
// @flow
import { observer } from "mobx-react";
import { TableOfContentsIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { MenuButton, useMenuState } from "reakit/Menu";
import Button from "components/Button";
import ContextMenu from "components/ContextMenu";
import Template from "components/ContextMenu/Template";
import { type MenuItem } from "types";
type Props = {|
headings: { title: string, level: number, id: string }[],
|};
function TableOfContentsMenu({ headings }: Props) {
const menu = useMenuState({
modal: true,
unstable_preventOverflow: true,
unstable_fixed: true,
unstable_flip: true,
});
const { t } = useTranslation();
const minHeading = headings.reduce(
(memo, heading) => (heading.level < memo ? heading.level : memo),
Infinity
);
const items: MenuItem[] = React.useMemo(() => {
let i = [
{
type: "heading",
visible: true,
title: t("Contents"),
},
...headings.map((heading) => ({
href: `#${heading.id}`,
title: t(heading.title),
level: heading.level - minHeading,
})),
];
if (i.length === 1) {
i.push({
href: "#",
title: t("Headings you add to the document will appear here"),
disabled: true,
});
}
return i;
}, [t, headings, minHeading]);
return (
<>
<MenuButton {...menu}>
{(props) => (
<Button
{...props}
icon={<TableOfContentsIcon />}
iconColor="currentColor"
borderOnHover
neutral
/>
)}
</MenuButton>
<ContextMenu {...menu} aria-label={t("Table of contents")}>
<Template {...menu} items={items} />
</ContextMenu>
</>
);
}
export default observer(TableOfContentsMenu);
+7 -4
View File
@@ -40,13 +40,13 @@ function TemplatesMenu({ document }: Props) {
{...menu}
>
<DocumentIcon />
<div>
<TemplateItem>
<strong>{template.titleWithDefault}</strong>
<br />
<Author>
{t("By {{ author }}", { author: template.createdBy.name })}
</Author>
</div>
</TemplateItem>
</MenuItem>
);
@@ -70,9 +70,12 @@ function TemplatesMenu({ document }: Props) {
);
}
const Author = styled.div`
font-size: 13px;
const TemplateItem = styled.div`
text-align: left;
`;
const Author = styled.div`
font-size: 13px;
`;
export default observer(TemplatesMenu);
+12 -2
View File
@@ -1,6 +1,7 @@
// @flow
import { addDays, differenceInDays } from "date-fns";
import invariant from "invariant";
import { floor } from "lodash";
import { action, computed, observable, set } from "mobx";
import parseTitle from "shared/utils/parseTitle";
import unescape from "shared/utils/unescape";
@@ -43,6 +44,7 @@ export default class Document extends BaseModel {
deletedAt: ?string;
url: string;
urlId: string;
tasks: { completed: number, total: number };
revision: number;
constructor(fields: Object, store: DocumentsStore) {
@@ -151,8 +153,16 @@ export default class Document extends BaseModel {
}
@computed
get placeholder(): ?string {
return this.isTemplate ? "Start your template…" : "Start with a title…";
get isTasks(): boolean {
return !!this.tasks.total;
}
@computed
get tasksPercentage(): number {
if (!this.isTasks) {
return 0;
}
return floor((this.tasks.completed / this.tasks.total) * 100);
}
@action
+2 -2
View File
@@ -14,7 +14,7 @@ import Trash from "scenes/Trash";
import CenteredContent from "components/CenteredContent";
import Layout from "components/Layout";
import LoadingPlaceholder from "components/LoadingPlaceholder";
import PlaceholderDocument from "components/PlaceholderDocument";
import Route from "components/ProfiledRoute";
import SocketProvider from "components/SocketProvider";
import { matchDocumentSlug as slug } from "utils/routeHelpers";
@@ -43,7 +43,7 @@ export default function AuthenticatedRoutes() {
<React.Suspense
fallback={
<CenteredContent>
<LoadingPlaceholder />
<PlaceholderDocument />
</CenteredContent>
}
>
+6 -4
View File
@@ -6,6 +6,7 @@ import Flex from "components/Flex";
import HelpText from "components/HelpText";
import Input from "components/Input";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
type Props = {|
onSubmit: () => void,
@@ -14,7 +15,8 @@ type Props = {|
function APITokenNew({ onSubmit }: Props) {
const [name, setName] = React.useState("");
const [isSaving, setIsSaving] = React.useState(false);
const { apiKeys, ui } = useStores();
const { apiKeys } = useStores();
const { showToast } = useToasts();
const { t } = useTranslation();
const handleSubmit = React.useCallback(async () => {
@@ -22,14 +24,14 @@ function APITokenNew({ onSubmit }: Props) {
try {
await apiKeys.create({ name });
ui.showToast(t("API token created", { type: "success" }));
showToast(t("API token created", { type: "success" }));
onSubmit();
} catch (err) {
ui.showToast(err.message, { type: "error" });
showToast(err.message, { type: "error" });
} finally {
setIsSaving(false);
}
}, [t, ui, name, onSubmit, apiKeys]);
}, [t, showToast, name, onSubmit, apiKeys]);
const handleNameChange = React.useCallback((event) => {
setName(event.target.value);
+14 -15
View File
@@ -27,11 +27,11 @@ import Flex from "components/Flex";
import Heading from "components/Heading";
import HelpText from "components/HelpText";
import InputSearchPage from "components/InputSearchPage";
import PlaceholderList from "components/List/Placeholder";
import LoadingIndicator from "components/LoadingIndicator";
import { ListPlaceholder } from "components/LoadingPlaceholder";
import Mask from "components/Mask";
import Modal from "components/Modal";
import PaginatedDocumentList from "components/PaginatedDocumentList";
import PlaceholderText from "components/PlaceholderText";
import Scene from "components/Scene";
import Subheading from "components/Subheading";
import Tab from "components/Tab";
@@ -39,9 +39,11 @@ import Tabs from "components/Tabs";
import Tooltip from "components/Tooltip";
import Collection from "../models/Collection";
import { updateCollectionUrl } from "../utils/routeHelpers";
import useBoolean from "hooks/useBoolean";
import useCurrentTeam from "hooks/useCurrentTeam";
import useImportDocument from "hooks/useImportDocument";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
import CollectionMenu from "menus/CollectionMenu";
import { newDocumentUrl, collectionUrl } from "utils/routeHelpers";
@@ -51,10 +53,15 @@ function CollectionScene() {
const match = useRouteMatch();
const { t } = useTranslation();
const { documents, policies, collections, ui } = useStores();
const { showToast } = useToasts();
const team = useCurrentTeam();
const [isFetching, setFetching] = React.useState();
const [error, setError] = React.useState();
const [permissionsModalOpen, setPermissionsModalOpen] = React.useState(false);
const [
permissionsModalOpen,
handlePermissionsModalOpen,
handlePermissionsModalClose,
] = useBoolean();
const id = params.id || "";
const collection: ?Collection =
@@ -102,20 +109,12 @@ function CollectionScene() {
load();
}, [collections, isFetching, collection, error, id, can]);
const handlePermissionsModalOpen = React.useCallback(() => {
setPermissionsModalOpen(true);
}, []);
const handlePermissionsModalClose = React.useCallback(() => {
setPermissionsModalOpen(false);
}, []);
const handleRejection = React.useCallback(() => {
ui.showToast(
showToast(
t("Document not supported try Markdown, Plain text, HTML, or Word"),
{ type: "error" }
);
}, [t, ui]);
}, [t, showToast]);
if (!collection && error) {
return <Search notFound />;
@@ -376,9 +375,9 @@ function CollectionScene() {
) : (
<CenteredContent>
<Heading>
<Mask height={35} />
<PlaceholderText height={35} />
</Heading>
<ListPlaceholder count={5} />
<PlaceholderList count={5} />
</CenteredContent>
);
}
+42 -44
View File
@@ -1,62 +1,60 @@
// @flow
import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import { observer } from "mobx-react";
import * as React from "react";
import { withRouter, type RouterHistory } from "react-router-dom";
import CollectionsStore from "stores/CollectionsStore";
import UiStore from "stores/UiStore";
import { useTranslation, Trans } from "react-i18next";
import { useHistory } from "react-router-dom";
import Collection from "models/Collection";
import Button from "components/Button";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
import useToasts from "hooks/useToasts";
import { homeUrl } from "utils/routeHelpers";
type Props = {
history: RouterHistory,
collection: Collection,
collections: CollectionsStore,
ui: UiStore,
onSubmit: () => void,
};
@observer
class CollectionDelete extends React.Component<Props> {
@observable isDeleting: boolean;
function CollectionDelete({ collection, onSubmit }: Props) {
const [isDeleting, setIsDeleting] = React.useState();
const { showToast } = useToasts();
const history = useHistory();
const { t } = useTranslation();
handleSubmit = async (ev: SyntheticEvent<>) => {
ev.preventDefault();
this.isDeleting = true;
const handleSubmit = React.useCallback(
async (ev: SyntheticEvent<>) => {
ev.preventDefault();
setIsDeleting(true);
try {
await this.props.collection.delete();
this.props.history.push(homeUrl());
this.props.onSubmit();
} catch (err) {
this.props.ui.showToast(err.message, { type: "error" });
} finally {
this.isDeleting = false;
}
};
try {
await collection.delete();
history.push(homeUrl());
onSubmit();
} catch (err) {
showToast(err.message, { type: "error" });
} finally {
setIsDeleting(false);
}
},
[showToast, onSubmit, collection, history]
);
render() {
const { collection } = this.props;
return (
<Flex column>
<form onSubmit={this.handleSubmit}>
<HelpText>
Are you sure about that? Deleting the{" "}
<strong>{collection.name}</strong> collection is permanent and
cannot be restored, however documents within will be moved to the
trash.
</HelpText>
<Button type="submit" disabled={this.isDeleting} autoFocus danger>
{this.isDeleting ? "Deleting…" : "Im sure  Delete"}
</Button>
</form>
</Flex>
);
}
return (
<Flex column>
<form onSubmit={handleSubmit}>
<HelpText>
<Trans
defaults="Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however documents within will be moved to the trash."
values={{ collectionName: collection.name }}
components={{ em: <strong /> }}
/>
</HelpText>
<Button type="submit" disabled={isDeleting} autoFocus danger>
{isDeleting ? `${t("Deleting")}` : t("Im sure  Delete")}
</Button>
</form>
</Flex>
);
}
export default inject("collections", "ui")(withRouter(CollectionDelete));
export default observer(CollectionDelete);
+5 -5
View File
@@ -4,7 +4,7 @@ import { inject, observer } from "mobx-react";
import * as React from "react";
import { withTranslation, Trans, type TFunction } from "react-i18next";
import AuthStore from "stores/AuthStore";
import UiStore from "stores/UiStore";
import ToastsStore from "stores/ToastsStore";
import Collection from "models/Collection";
import Button from "components/Button";
import Flex from "components/Flex";
@@ -16,7 +16,7 @@ import Switch from "components/Switch";
type Props = {
collection: Collection,
ui: UiStore,
toasts: ToastsStore,
auth: AuthStore,
onSubmit: () => void,
t: TFunction,
@@ -46,11 +46,11 @@ class CollectionEdit extends React.Component<Props> {
sort: this.sort,
});
this.props.onSubmit();
this.props.ui.showToast(t("The collection was updated"), {
this.props.toasts.showToast(t("The collection was updated"), {
type: "success",
});
} catch (err) {
this.props.ui.showToast(err.message, { type: "error" });
this.props.toasts.showToast(err.message, { type: "error" });
} finally {
this.isSaving = false;
}
@@ -148,5 +148,5 @@ class CollectionEdit extends React.Component<Props> {
}
export default withTranslation()<CollectionEdit>(
inject("ui", "auth")(CollectionEdit)
inject("toasts", "auth")(CollectionEdit)
);
+32 -36
View File
@@ -1,9 +1,7 @@
// @flow
import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import { observer } from "mobx-react";
import * as React from "react";
import AuthStore from "stores/AuthStore";
import UiStore from "stores/UiStore";
import { useTranslation, Trans } from "react-i18next";
import Collection from "models/Collection";
import Button from "components/Button";
import Flex from "components/Flex";
@@ -11,43 +9,41 @@ import HelpText from "components/HelpText";
type Props = {
collection: Collection,
auth: AuthStore,
ui: UiStore,
onSubmit: () => void,
};
@observer
class CollectionExport extends React.Component<Props> {
@observable isLoading: boolean = false;
function CollectionExport({ collection, onSubmit }: Props) {
const [isLoading, setIsLoading] = React.useState();
const { t } = useTranslation();
handleSubmit = async (ev: SyntheticEvent<>) => {
ev.preventDefault();
const handleSubmit = React.useCallback(
async (ev: SyntheticEvent<>) => {
ev.preventDefault();
this.isLoading = true;
await this.props.collection.export();
this.isLoading = false;
this.props.onSubmit();
};
setIsLoading(true);
await collection.export();
setIsLoading(false);
onSubmit();
},
[collection, onSubmit]
);
render() {
const { collection, auth } = this.props;
if (!auth.user) return null;
return (
<Flex column>
<form onSubmit={this.handleSubmit}>
<HelpText>
Exporting the collection <strong>{collection.name}</strong> may take
a few seconds. Your documents will be downloaded as a zip of folders
with files in Markdown format.
</HelpText>
<Button type="submit" disabled={this.isLoading} primary>
{this.isLoading ? "Exporting…" : "Export Collection"}
</Button>
</form>
</Flex>
);
}
return (
<Flex column>
<form onSubmit={handleSubmit}>
<HelpText>
<Trans
defaults="Exporting the collection <em>{{collectionName}}</em> may take a few seconds. Your documents will be downloaded as a zip of folders with files in Markdown format."
values={{ collectionName: collection.name }}
components={{ em: <strong /> }}
/>
</HelpText>
<Button type="submit" disabled={isLoading} primary>
{isLoading ? `${t("Exporting")}` : t("Export Collection")}
</Button>
</form>
</Flex>
);
}
export default inject("ui", "auth")(CollectionExport);
export default observer(CollectionExport);
+4 -4
View File
@@ -7,7 +7,7 @@ import { withTranslation, type TFunction, Trans } from "react-i18next";
import { withRouter, type RouterHistory } from "react-router-dom";
import AuthStore from "stores/AuthStore";
import CollectionsStore from "stores/CollectionsStore";
import UiStore from "stores/UiStore";
import ToastsStore from "stores/ToastsStore";
import Collection from "models/Collection";
import Button from "components/Button";
import Flex from "components/Flex";
@@ -20,7 +20,7 @@ import Switch from "components/Switch";
type Props = {
history: RouterHistory,
auth: AuthStore,
ui: UiStore,
toasts: ToastsStore,
collections: CollectionsStore,
onSubmit: () => void,
t: TFunction,
@@ -55,7 +55,7 @@ class CollectionNew extends React.Component<Props> {
this.props.onSubmit();
this.props.history.push(collection.url);
} catch (err) {
this.props.ui.showToast(err.message, { type: "error" });
this.props.toasts.showToast(err.message, { type: "error" });
} finally {
this.isSaving = false;
}
@@ -169,5 +169,5 @@ class CollectionNew extends React.Component<Props> {
}
export default withTranslation()<CollectionNew>(
inject("collections", "ui", "auth")(withRouter(CollectionNew))
inject("collections", "toasts", "auth")(withRouter(CollectionNew))
);
@@ -8,7 +8,7 @@ import styled from "styled-components";
import AuthStore from "stores/AuthStore";
import CollectionGroupMembershipsStore from "stores/CollectionGroupMembershipsStore";
import GroupsStore from "stores/GroupsStore";
import UiStore from "stores/UiStore";
import ToastsStore from "stores/ToastsStore";
import Collection from "models/Collection";
import Group from "models/Group";
import GroupNew from "scenes/GroupNew";
@@ -23,7 +23,7 @@ import Modal from "components/Modal";
import PaginatedList from "components/PaginatedList";
type Props = {
ui: UiStore,
toasts: ToastsStore,
auth: AuthStore,
collection: Collection,
collectionGroupMemberships: CollectionGroupMembershipsStore,
@@ -65,14 +65,14 @@ class AddGroupsToCollection extends React.Component<Props> {
groupId: group.id,
permission: "read_write",
});
this.props.ui.showToast(
this.props.toasts.showToast(
t("{{ groupName }} was added to the collection", {
groupName: group.name,
}),
{ type: "success" }
);
} catch (err) {
this.props.ui.showToast(t("Could not add user"), { type: "error" });
this.props.toasts.showToast(t("Could not add user"), { type: "error" });
console.error(err);
}
};
@@ -147,6 +147,6 @@ export default withTranslation()<AddGroupsToCollection>(
"auth",
"groups",
"collectionGroupMemberships",
"ui"
"toasts"
)(AddGroupsToCollection)
);
@@ -6,7 +6,7 @@ import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import AuthStore from "stores/AuthStore";
import MembershipsStore from "stores/MembershipsStore";
import UiStore from "stores/UiStore";
import ToastsStore from "stores/ToastsStore";
import UsersStore from "stores/UsersStore";
import Collection from "models/Collection";
import User from "models/User";
@@ -21,7 +21,7 @@ import PaginatedList from "components/PaginatedList";
import MemberListItem from "./components/MemberListItem";
type Props = {
ui: UiStore,
toasts: ToastsStore,
auth: AuthStore,
collection: Collection,
memberships: MembershipsStore,
@@ -62,14 +62,14 @@ class AddPeopleToCollection extends React.Component<Props> {
userId: user.id,
permission: "read_write",
});
this.props.ui.showToast(
this.props.toasts.showToast(
t("{{ userName }} was added to the collection", {
userName: user.name,
}),
{ type: "success" }
);
} catch (err) {
this.props.ui.showToast(t("Could not add user"), { type: "error" });
this.props.toasts.showToast(t("Could not add user"), { type: "error" });
}
};
@@ -130,5 +130,5 @@ class AddPeopleToCollection extends React.Component<Props> {
}
export default withTranslation()<AddPeopleToCollection>(
inject("auth", "users", "memberships", "ui")(AddPeopleToCollection)
inject("auth", "users", "memberships", "toasts")(AddPeopleToCollection)
);
+34 -24
View File
@@ -17,8 +17,10 @@ import AddGroupsToCollection from "./AddGroupsToCollection";
import AddPeopleToCollection from "./AddPeopleToCollection";
import CollectionGroupMemberListItem from "./components/CollectionGroupMemberListItem";
import MemberListItem from "./components/MemberListItem";
import useBoolean from "hooks/useBoolean";
import useCurrentUser from "hooks/useCurrentUser";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
type Props = {|
collection: Collection,
@@ -28,14 +30,22 @@ function CollectionPermissions({ collection }: Props) {
const { t } = useTranslation();
const user = useCurrentUser();
const {
ui,
memberships,
collectionGroupMemberships,
users,
groups,
} = useStores();
const [addGroupModalOpen, setAddGroupModalOpen] = React.useState(false);
const [addMemberModalOpen, setAddMemberModalOpen] = React.useState(false);
const { showToast } = useToasts();
const [
addGroupModalOpen,
handleAddGroupModalOpen,
handleAddGroupModalClose,
] = useBoolean();
const [
addMemberModalOpen,
handleAddMemberModalOpen,
handleAddMemberModalClose,
] = useBoolean();
const handleRemoveUser = React.useCallback(
async (user) => {
@@ -44,7 +54,7 @@ function CollectionPermissions({ collection }: Props) {
collectionId: collection.id,
userId: user.id,
});
ui.showToast(
showToast(
t(`{{ userName }} was removed from the collection`, {
userName: user.name,
}),
@@ -53,10 +63,10 @@ function CollectionPermissions({ collection }: Props) {
}
);
} catch (err) {
ui.showToast(t("Could not remove user"), { type: "error" });
showToast(t("Could not remove user"), { type: "error" });
}
},
[memberships, ui, collection, t]
[memberships, showToast, collection, t]
);
const handleUpdateUser = React.useCallback(
@@ -67,17 +77,17 @@ function CollectionPermissions({ collection }: Props) {
userId: user.id,
permission,
});
ui.showToast(
showToast(
t(`{{ userName }} permissions were updated`, { userName: user.name }),
{
type: "success",
}
);
} catch (err) {
ui.showToast(t("Could not update user"), { type: "error" });
showToast(t("Could not update user"), { type: "error" });
}
},
[memberships, ui, collection, t]
[memberships, showToast, collection, t]
);
const handleRemoveGroup = React.useCallback(
@@ -87,7 +97,7 @@ function CollectionPermissions({ collection }: Props) {
collectionId: collection.id,
groupId: group.id,
});
ui.showToast(
showToast(
t(`The {{ groupName }} group was removed from the collection`, {
groupName: group.name,
}),
@@ -96,10 +106,10 @@ function CollectionPermissions({ collection }: Props) {
}
);
} catch (err) {
ui.showToast(t("Could not remove group"), { type: "error" });
showToast(t("Could not remove group"), { type: "error" });
}
},
[collectionGroupMemberships, ui, collection, t]
[collectionGroupMemberships, showToast, collection, t]
);
const handleUpdateGroup = React.useCallback(
@@ -110,7 +120,7 @@ function CollectionPermissions({ collection }: Props) {
groupId: group.id,
permission,
});
ui.showToast(
showToast(
t(`{{ groupName }} permissions were updated`, {
groupName: group.name,
}),
@@ -119,24 +129,24 @@ function CollectionPermissions({ collection }: Props) {
}
);
} catch (err) {
ui.showToast(t("Could not update user"), { type: "error" });
showToast(t("Could not update user"), { type: "error" });
}
},
[collectionGroupMemberships, ui, collection, t]
[collectionGroupMemberships, showToast, collection, t]
);
const handleChangePermission = React.useCallback(
async (ev) => {
try {
await collection.save({ permission: ev.target.value });
ui.showToast(t("Default access permissions were updated"), {
showToast(t("Default access permissions were updated"), {
type: "success",
});
} catch (err) {
ui.showToast(t("Could not update permissions"), { type: "error" });
showToast(t("Could not update permissions"), { type: "error" });
}
},
[collection, ui, t]
[collection, showToast, t]
);
const fetchOptions = React.useMemo(() => ({ id: collection.id }), [
@@ -183,7 +193,7 @@ function CollectionPermissions({ collection }: Props) {
<Actions>
<Button
type="button"
onClick={() => setAddGroupModalOpen(true)}
onClick={handleAddGroupModalOpen}
icon={<PlusIcon />}
neutral
>
@@ -191,7 +201,7 @@ function CollectionPermissions({ collection }: Props) {
</Button>{" "}
<Button
type="button"
onClick={() => setAddMemberModalOpen(true)}
onClick={handleAddMemberModalOpen}
icon={<PlusIcon />}
neutral
>
@@ -244,24 +254,24 @@ function CollectionPermissions({ collection }: Props) {
title={t(`Add groups to {{ collectionName }}`, {
collectionName: collection.name,
})}
onRequestClose={() => setAddGroupModalOpen(false)}
onRequestClose={handleAddGroupModalClose}
isOpen={addGroupModalOpen}
>
<AddGroupsToCollection
collection={collection}
onSubmit={() => setAddGroupModalOpen(false)}
onSubmit={handleAddGroupModalClose}
/>
</Modal>
<Modal
title={t(`Add people to {{ collectionName }}`, {
collectionName: collection.name,
})}
onRequestClose={() => setAddMemberModalOpen(false)}
onRequestClose={handleAddMemberModalClose}
isOpen={addMemberModalOpen}
>
<AddPeopleToCollection
collection={collection}
onSubmit={() => setAddMemberModalOpen(false)}
onSubmit={handleAddMemberModalClose}
/>
</Modal>
</Flex>
@@ -70,11 +70,13 @@ const Wrapper = styled("div")`
display: none;
position: sticky;
top: 80px;
max-height: calc(100vh - 80px);
box-shadow: 1px 0 0 ${(props) => props.theme.divider};
margin-top: 40px;
margin-right: 2em;
min-height: 40px;
overflow-y: auto;
${breakpoint("desktopLarge")`
margin-left: -16em;
+60 -29
View File
@@ -4,12 +4,15 @@ import { observable } from "mobx";
import { observer, inject } from "mobx-react";
import { InputIcon } from "outline-icons";
import * as React from "react";
import { type TFunction, Trans, withTranslation } from "react-i18next";
import keydown from "react-keydown";
import { Prompt, Route, withRouter } from "react-router-dom";
import type { RouterHistory, Match } from "react-router-dom";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import getTasks from "shared/utils/getTasks";
import AuthStore from "stores/AuthStore";
import ToastsStore from "stores/ToastsStore";
import UiStore from "stores/UiStore";
import Document from "models/Document";
import Revision from "models/Revision";
@@ -18,10 +21,10 @@ import Branding from "components/Branding";
import ErrorBoundary from "components/ErrorBoundary";
import Flex from "components/Flex";
import LoadingIndicator from "components/LoadingIndicator";
import LoadingPlaceholder from "components/LoadingPlaceholder";
import Modal from "components/Modal";
import Notice from "components/Notice";
import PageTitle from "components/PageTitle";
import PlaceholderDocument from "components/PlaceholderDocument";
import Time from "components/Time";
import Container from "./Container";
import Contents from "./Contents";
@@ -44,15 +47,6 @@ import {
const AUTOSAVE_DELAY = 3000;
const IS_DIRTY_DELAY = 500;
const DISCARD_CHANGES = `
You have unsaved changes.
Are you sure you want to discard them?
`;
const UPLOADING_WARNING = `
Images are still uploading.
Are you sure you want to discard them?
`;
type Props = {
match: Match,
history: RouterHistory,
@@ -67,6 +61,8 @@ type Props = {
theme: Theme,
auth: AuthStore,
ui: UiStore,
toasts: ToastsStore,
t: TFunction,
};
@observer
@@ -81,8 +77,12 @@ class DocumentScene extends React.Component<Props> {
@observable title: string = this.props.document.title;
getEditorText: () => string = () => this.props.document.text;
componentDidMount() {
this.updateIsDirty();
}
componentDidUpdate(prevProps) {
const { auth, document } = this.props;
const { auth, document, t } = this.props;
if (prevProps.readOnly && !this.props.readOnly) {
this.updateIsDirty();
@@ -96,8 +96,10 @@ class DocumentScene extends React.Component<Props> {
}
} else if (prevProps.document.revision !== this.lastRevision) {
if (auth.user && document.updatedBy.id !== auth.user.id) {
this.props.ui.showToast(
`Document updated by ${document.updatedBy.name}`,
this.props.toasts.showToast(
t(`Document updated by {{userName}}`, {
userName: document.updatedBy.name,
}),
{
timeout: 30 * 1000,
type: "warning",
@@ -116,6 +118,7 @@ class DocumentScene extends React.Component<Props> {
document.injectTemplate = false;
this.title = document.title;
this.isDirty = true;
this.updateIsDirty();
}
}
@@ -221,6 +224,8 @@ class DocumentScene extends React.Component<Props> {
this.isSaving = true;
this.isPublishing = !!options.publish;
document.tasks = getTasks(document.text);
try {
const savedDocument = await document.save({
...options,
@@ -237,7 +242,7 @@ class DocumentScene extends React.Component<Props> {
this.props.ui.setActiveDocument(savedDocument);
}
} catch (err) {
this.props.ui.showToast(err.message, { type: "error" });
this.props.toasts.showToast(err.message, { type: "error" });
} finally {
this.isSaving = false;
this.isPublishing = false;
@@ -302,6 +307,7 @@ class DocumentScene extends React.Component<Props> {
auth,
ui,
match,
t,
} = this.props;
const team = auth.team;
const { shareId } = match.params;
@@ -351,11 +357,15 @@ class DocumentScene extends React.Component<Props> {
<>
<Prompt
when={this.isDirty && !this.isUploading}
message={DISCARD_CHANGES}
message={t(
`You have unsaved changes.\nAre you sure you want to discard them?`
)}
/>
<Prompt
when={this.isUploading && !this.isDirty}
message={UPLOADING_WARNING}
message={t(
`Images are still uploading.\nAre you sure you want to discard them?`
)}
/>
</>
)}
@@ -374,6 +384,7 @@ class DocumentScene extends React.Component<Props> {
sharedTree={this.props.sharedTree}
goBack={this.goBack}
onSave={this.onSave}
headings={headings}
/>
<MaxWidth
archived={document.isArchived}
@@ -383,33 +394,51 @@ class DocumentScene extends React.Component<Props> {
>
{document.isTemplate && !readOnly && (
<Notice muted>
Youre editing a template. Highlight some text and use the{" "}
<PlaceholderIcon color="currentColor" /> control to add
placeholders that can be filled out when creating new
documents from this template.
<Trans>
Youre editing a template. Highlight some text and use the{" "}
<PlaceholderIcon color="currentColor" /> control to add
placeholders that can be filled out when creating new
documents from this template.
</Trans>
</Notice>
)}
{document.archivedAt && !document.deletedAt && (
<Notice muted>
Archived by {document.updatedBy.name}{" "}
<Time dateTime={document.archivedAt} /> ago
{t("Archived by {{userName}}", {
userName: document.updatedBy.name,
})}{" "}
<Time dateTime={document.updatedAt} addSuffix />
</Notice>
)}
{document.deletedAt && (
<Notice muted>
Deleted by {document.updatedBy.name}{" "}
<Time dateTime={document.deletedAt} /> ago
<strong>
{t("Deleted by {{userName}}", {
userName: document.updatedBy.name,
})}{" "}
<Time dateTime={document.deletedAt || ""} addSuffix />
</strong>
{document.permanentlyDeletedAt && (
<>
<br />
This {document.noun} will be permanently deleted in{" "}
<Time dateTime={document.permanentlyDeletedAt} /> unless
restored.
{document.template ? (
<Trans>
This template will be permanently deleted in{" "}
<Time dateTime={document.permanentlyDeletedAt} />{" "}
unless restored.
</Trans>
) : (
<Trans>
This document will be permanently deleted in{" "}
<Time dateTime={document.permanentlyDeletedAt} />{" "}
unless restored.
</Trans>
)}
</>
)}
</Notice>
)}
<React.Suspense fallback={<LoadingPlaceholder />}>
<React.Suspense fallback={<PlaceholderDocument />}>
<Flex auto={!readOnly}>
{showContents && <Contents headings={headings} />}
<Editor
@@ -507,5 +536,7 @@ const MaxWidth = styled(Flex)`
`;
export default withRouter(
inject("ui", "auth", "policies", "revisions")(DocumentScene)
withTranslation()<DocumentScene>(
inject("ui", "auth", "toasts")(DocumentScene)
)
);
+21 -6
View File
@@ -1,13 +1,15 @@
// @flow
import { observable } from "mobx";
import { observer } from "mobx-react";
import { inject, observer } from "mobx-react";
import * as React from "react";
import Textarea from "react-autosize-textarea";
import { type TFunction, withTranslation } from "react-i18next";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { MAX_TITLE_LENGTH } from "shared/constants";
import { light } from "shared/styles/theme";
import { light } from "shared/theme";
import parseTitle from "shared/utils/parseTitle";
import PoliciesStore from "stores/PoliciesStore";
import Document from "models/Document";
import ClickablePadding from "components/ClickablePadding";
import DocumentMetaWithViews from "components/DocumentMetaWithViews";
@@ -28,6 +30,8 @@ type Props = {|
onSave: ({ done?: boolean, autosave?: boolean, publish?: boolean }) => any,
innerRef: { current: any },
children: React.Node,
policies: PoliciesStore,
t: TFunction,
|};
@observer
@@ -102,9 +106,12 @@ class DocumentEditor extends React.Component<Props> {
readOnly,
innerRef,
children,
policies,
t,
...rest
} = this.props;
const can = policies.abilities(document.id);
const { emoji } = parseTitle(title);
const startsWithEmojiAndSpace = !!(emoji && title.startsWith(`${emoji} `));
const normalizedTitle =
@@ -121,7 +128,9 @@ class DocumentEditor extends React.Component<Props> {
dir="auto"
>
<span>{normalizedTitle}</span>{" "}
{!shareId && <StarButton document={document} size={32} />}
{(can.star || can.unstar) && (
<StarButton document={document} size={32} />
)}
</Title>
) : (
<Title
@@ -129,7 +138,11 @@ class DocumentEditor extends React.Component<Props> {
ref={this.ref}
onChange={onChangeTitle}
onKeyDown={this.handleTitleKeyDown}
placeholder={document.placeholder}
placeholder={
document.isTemplate
? t("Start your template…")
: t("Start with a title…")
}
value={normalizedTitle}
$startsWithEmojiAndSpace={startsWithEmojiAndSpace}
autoFocus={!title}
@@ -152,7 +165,7 @@ class DocumentEditor extends React.Component<Props> {
<Editor
ref={innerRef}
autoFocus={!!title && !this.props.defaultValue}
placeholder="…the rest is up to you"
placeholder={t("…the rest is up to you")}
onHoverLink={this.handleLinkActive}
scrollTo={window.location.hash}
readOnly={readOnly}
@@ -224,4 +237,6 @@ const Title = styled(Textarea)`
}
`;
export default DocumentEditor;
export default withTranslation()<DocumentEditor>(
inject("policies")(DocumentEditor)
);
+14 -1
View File
@@ -24,6 +24,7 @@ import useMobile from "hooks/useMobile";
import useStores from "hooks/useStores";
import DocumentMenu from "menus/DocumentMenu";
import NewChildDocumentMenu from "menus/NewChildDocumentMenu";
import TableOfContentsMenu from "menus/TableOfContentsMenu";
import TemplatesMenu from "menus/TemplatesMenu";
import { type NavigationNode } from "types";
import { metaDisplay } from "utils/keyboard";
@@ -46,6 +47,7 @@ type Props = {|
publish?: boolean,
autosave?: boolean,
}) => void,
headings: { title: string, level: number, id: string }[],
|};
function DocumentHeader({
@@ -60,6 +62,7 @@ function DocumentHeader({
publishingIsDisabled,
sharedTree,
onSave,
headings,
}: Props) {
const { t } = useTranslation();
const { auth, ui, policies } = useStores();
@@ -73,7 +76,7 @@ function DocumentHeader({
onSave({ done: true, publish: true });
}, [onSave]);
const isNew = document.isNew;
const isNew = document.isNewDocument;
const isTemplate = document.isTemplate;
const can = policies.abilities(document.id);
const canShareDocument = auth.team && auth.team.sharing && can.share;
@@ -153,6 +156,11 @@ function DocumentHeader({
}
actions={
<>
{isMobile && (
<TocWrapper>
<TableOfContentsMenu headings={headings} />
</TocWrapper>
)}
{!isPublishing && isSaving && <Status>{t("Saving")}</Status>}
<Collaborators
document={document}
@@ -274,4 +282,9 @@ const Status = styled(Action)`
color: ${(props) => props.theme.slate};
`;
const TocWrapper = styled(Action)`
position: absolute;
left: 42px;
`;
export default observer(DocumentHeader);
@@ -8,20 +8,15 @@ import KeyboardShortcuts from "scenes/KeyboardShortcuts";
import Guide from "components/Guide";
import NudeButton from "components/NudeButton";
import Tooltip from "components/Tooltip";
import useBoolean from "hooks/useBoolean";
function KeyboardShortcutsButton() {
const { t } = useTranslation();
const [keyboardShortcutsOpen, setKeyboardShortcutsOpen] = React.useState(
false
);
const handleCloseKeyboardShortcuts = React.useCallback(() => {
setKeyboardShortcutsOpen(false);
}, []);
const handleOpenKeyboardShortcuts = React.useCallback(() => {
setKeyboardShortcutsOpen(true);
}, []);
const [
keyboardShortcutsOpen,
handleOpenKeyboardShortcuts,
handleCloseKeyboardShortcuts,
] = useBoolean();
return (
<>
+2 -2
View File
@@ -2,8 +2,8 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import CenteredContent from "components/CenteredContent";
import LoadingPlaceholder from "components/LoadingPlaceholder";
import PageTitle from "components/PageTitle";
import PlaceholderDocument from "components/PlaceholderDocument";
import Container from "./Container";
import type { LocationWithState } from "types";
@@ -20,7 +20,7 @@ export default function Loading({ location }: Props) {
title={location.state ? location.state.title : t("Untitled")}
/>
<CenteredContent>
<LoadingPlaceholder />
<PlaceholderDocument />
</CenteredContent>
</Container>
);
@@ -16,6 +16,7 @@ import Input from "components/Input";
import Notice from "components/Notice";
import Switch from "components/Switch";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
type Props = {|
document: Document,
@@ -26,7 +27,8 @@ type Props = {|
function SharePopover({ document, share, sharedParent, onSubmit }: Props) {
const { t } = useTranslation();
const { policies, shares, ui } = useStores();
const { policies, shares } = useStores();
const { showToast } = useToasts();
const [isCopied, setIsCopied] = React.useState(false);
const timeout = React.useRef<?TimeoutID>();
const can = policies.abilities(share ? share.id : "");
@@ -46,10 +48,10 @@ function SharePopover({ document, share, sharedParent, onSubmit }: Props) {
try {
await share.save({ published: event.currentTarget.checked });
} catch (err) {
ui.showToast(err.message, { type: "error" });
showToast(err.message, { type: "error" });
}
},
[document.id, shares, ui]
[document.id, shares, showToast]
);
const handleChildDocumentsChange = React.useCallback(
@@ -62,10 +64,10 @@ function SharePopover({ document, share, sharedParent, onSubmit }: Props) {
includeChildDocuments: event.currentTarget.checked,
});
} catch (err) {
ui.showToast(err.message, { type: "error" });
showToast(err.message, { type: "error" });
}
},
[document.id, shares, ui]
[document.id, shares, showToast]
);
const handleCopied = React.useCallback(() => {
@@ -75,9 +77,9 @@ function SharePopover({ document, share, sharedParent, onSubmit }: Props) {
setIsCopied(false);
onSubmit();
ui.showToast(t("Share link copied"), { type: "info" });
showToast(t("Share link copied"), { type: "info" });
}, 250);
}, [t, onSubmit, ui]);
}, [t, onSubmit, showToast]);
return (
<>
+2 -1
View File
@@ -8,6 +8,7 @@ import Button from "components/Button";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
import { collectionUrl, documentUrl } from "utils/routeHelpers";
type Props = {
@@ -21,7 +22,7 @@ function DocumentDelete({ document, onSubmit }: Props) {
const history = useHistory();
const [isDeleting, setDeleting] = React.useState(false);
const [isArchiving, setArchiving] = React.useState(false);
const { showToast } = ui;
const { showToast } = useToasts();
const canArchive = !document.isDraft && !document.isArchived;
const collection = collections.get(document.collectionId);
+75 -86
View File
@@ -1,37 +1,34 @@
// @flow
import { Search } from "js-search";
import { last } from "lodash";
import { observable, computed } from "mobx";
import { observer, inject } from "mobx-react";
import { observer } from "mobx-react";
import * as React from "react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import AutoSizer from "react-virtualized-auto-sizer";
import { FixedSizeList as List } from "react-window";
import styled from "styled-components";
import CollectionsStore, { type DocumentPath } from "stores/CollectionsStore";
import DocumentsStore from "stores/DocumentsStore";
import UiStore from "stores/UiStore";
import { type DocumentPath } from "stores/CollectionsStore";
import Document from "models/Document";
import Flex from "components/Flex";
import { Outline } from "components/Input";
import Labeled from "components/Labeled";
import PathToDocument from "components/PathToDocument";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
type Props = {|
document: Document,
documents: DocumentsStore,
collections: CollectionsStore,
ui: UiStore,
onRequestClose: () => void,
|};
@observer
class DocumentMove extends React.Component<Props> {
@observable searchTerm: ?string;
@observable isSaving: boolean;
function DocumentMove({ document, onRequestClose }: Props) {
const [searchTerm, setSearchTerm] = useState();
const { collections, documents } = useStores();
const { showToast } = useToasts();
const { t } = useTranslation();
@computed
get searchIndex() {
const { collections, documents } = this.props;
const searchIndex = useMemo(() => {
const paths = collections.pathsToDocuments;
const index = new Search("id");
index.addIndex("title");
@@ -47,19 +44,16 @@ class DocumentMove extends React.Component<Props> {
index.addDocuments(indexeableDocuments);
return index;
}
}, [documents, collections.pathsToDocuments]);
@computed
get results(): DocumentPath[] {
const { document, collections } = this.props;
const results: DocumentPath[] = useMemo(() => {
const onlyShowCollections = document.isTemplate;
let results = [];
if (collections.isLoaded) {
if (this.searchTerm) {
results = this.searchIndex.search(this.searchTerm);
if (searchTerm) {
results = searchIndex.search(searchTerm);
} else {
results = this.searchIndex._documents;
results = searchIndex._documents;
}
}
@@ -82,19 +76,18 @@ class DocumentMove extends React.Component<Props> {
}
return results;
}
}, [document, collections, searchTerm, searchIndex]);
handleSuccess = () => {
this.props.ui.showToast("Document moved", { type: "info" });
this.props.onRequestClose();
const handleSuccess = () => {
showToast(t("Document moved"), { type: "info" });
onRequestClose();
};
handleFilter = (ev: SyntheticInputEvent<*>) => {
this.searchTerm = ev.target.value;
const handleFilter = (ev: SyntheticInputEvent<*>) => {
setSearchTerm(ev.target.value);
};
renderPathToCurrentDocument() {
const { collections, document } = this.props;
const renderPathToCurrentDocument = () => {
const result = collections.getPathForDocument(document.id);
if (result) {
@@ -105,75 +98,71 @@ class DocumentMove extends React.Component<Props> {
/>
);
}
}
};
row = ({ index, data, style }) => {
const row = ({ index, data, style }) => {
const result = data[index];
const { document, collections } = this.props;
return (
<PathToDocument
result={result}
document={document}
collection={collections.get(result.collectionId)}
onSuccess={this.handleSuccess}
onSuccess={handleSuccess}
style={style}
/>
);
};
render() {
const { document, collections } = this.props;
const data = this.results;
const data = results;
if (!document || !collections.isLoaded) {
return null;
}
return (
<Flex column>
<Section>
<Labeled label="Current location">
{this.renderPathToCurrentDocument()}
</Labeled>
</Section>
<Section column>
<Labeled label="Choose a new location" />
<NewLocation>
<InputWrapper>
<Input
type="search"
placeholder="Search collections & documents…"
onChange={this.handleFilter}
required
autoFocus
/>
</InputWrapper>
<Results>
<AutoSizer>
{({ width, height }) => (
<Flex role="listbox" column>
<List
key={data.length}
width={width}
height={height}
itemData={data}
itemCount={data.length}
itemSize={40}
itemKey={(index, data) => data[index].id}
>
{this.row}
</List>
</Flex>
)}
</AutoSizer>
</Results>
</NewLocation>
</Section>
</Flex>
);
if (!document || !collections.isLoaded) {
return null;
}
return (
<Flex column>
<Section>
<Labeled label={t("Current location")}>
{renderPathToCurrentDocument()}
</Labeled>
</Section>
<Section column>
<Labeled label={t("Choose a new location")} />
<NewLocation>
<InputWrapper>
<Input
type="search"
placeholder={`${t("Search collections & documents")}`}
onChange={handleFilter}
required
autoFocus
/>
</InputWrapper>
<Results>
<AutoSizer>
{({ width, height }) => (
<Flex role="listbox" column>
<List
key={data.length}
width={width}
height={height}
itemData={data}
itemCount={data.length}
itemSize={40}
itemKey={(index, data) => data[index].id}
>
{row}
</List>
</Flex>
)}
</AutoSizer>
</Results>
</NewLocation>
</Section>
</Flex>
);
}
const InputWrapper = styled("div")`
@@ -210,4 +199,4 @@ const Section = styled(Flex)`
margin-bottom: 24px;
`;
export default inject("documents", "collections", "ui")(DocumentMove);
export default observer(DocumentMove);
+6 -4
View File
@@ -7,8 +7,9 @@ import { useTranslation } from "react-i18next";
import { useHistory, useLocation, useRouteMatch } from "react-router-dom";
import CenteredContent from "components/CenteredContent";
import Flex from "components/Flex";
import LoadingPlaceholder from "components/LoadingPlaceholder";
import PlaceholderDocument from "components/PlaceholderDocument";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
import { editDocumentUrl } from "utils/routeHelpers";
function DocumentNew() {
@@ -16,7 +17,8 @@ function DocumentNew() {
const location = useLocation();
const match = useRouteMatch();
const { t } = useTranslation();
const { documents, ui, collections } = useStores();
const { documents, collections } = useStores();
const { showToast } = useToasts();
const id = match.params.id || "";
useEffect(() => {
@@ -36,7 +38,7 @@ function DocumentNew() {
history.replace(editDocumentUrl(document));
} catch (err) {
ui.showToast(t("Couldnt create the document, try again?"), {
showToast(t("Couldnt create the document, try again?"), {
type: "error",
});
history.goBack();
@@ -48,7 +50,7 @@ function DocumentNew() {
return (
<Flex column auto>
<CenteredContent>
<LoadingPlaceholder />
<PlaceholderDocument />
</CenteredContent>
</Flex>
);
+3 -2
View File
@@ -8,6 +8,7 @@ import Button from "components/Button";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
type Props = {|
document: Document,
@@ -17,8 +18,8 @@ type Props = {|
function DocumentPermanentDelete({ document, onSubmit }: Props) {
const [isDeleting, setIsDeleting] = React.useState(false);
const { t } = useTranslation();
const { ui, documents } = useStores();
const { showToast } = ui;
const { documents } = useStores();
const { showToast } = useToasts();
const history = useHistory();
const handleSubmit = React.useCallback(
+46 -45
View File
@@ -1,63 +1,64 @@
// @flow
import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import { observer } from "mobx-react";
import * as React from "react";
import { withRouter, type RouterHistory } from "react-router-dom";
import UiStore from "stores/UiStore";
import { useState } from "react";
import { useTranslation, Trans } from "react-i18next";
import { useHistory } from "react-router-dom";
import Document from "models/Document";
import Button from "components/Button";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
import useToasts from "hooks/useToasts";
import { documentUrl } from "utils/routeHelpers";
type Props = {
ui: UiStore,
document: Document,
history: RouterHistory,
onSubmit: () => void,
};
@observer
class DocumentTemplatize extends React.Component<Props> {
@observable isSaving: boolean;
function DocumentTemplatize({ document, onSubmit }: Props) {
const [isSaving, setIsSaving] = useState();
const history = useHistory();
const { showToast } = useToasts();
const { t } = useTranslation();
handleSubmit = async (ev: SyntheticEvent<>) => {
ev.preventDefault();
this.isSaving = true;
const handleSubmit = React.useCallback(
async (ev: SyntheticEvent<>) => {
ev.preventDefault();
setIsSaving(true);
try {
const template = await this.props.document.templatize();
this.props.history.push(documentUrl(template));
this.props.ui.showToast("Template created, go ahead and customize it", {
type: "info",
});
this.props.onSubmit();
} catch (err) {
this.props.ui.showToast(err.message, { type: "error" });
} finally {
this.isSaving = false;
}
};
try {
const template = await document.templatize();
history.push(documentUrl(template));
showToast(t("Template created, go ahead and customize it"), {
type: "info",
});
onSubmit();
} catch (err) {
showToast(err.message, { type: "error" });
} finally {
setIsSaving(false);
}
},
[document, showToast, history, onSubmit, t]
);
render() {
const { document } = this.props;
return (
<Flex column>
<form onSubmit={this.handleSubmit}>
<HelpText>
Creating a template from{" "}
<strong>{document.titleWithDefault}</strong> is a non-destructive
action we'll make a copy of the document and turn it into a
template that can be used as a starting point for new documents.
</HelpText>
<Button type="submit">
{this.isSaving ? "Creating…" : "Create template"}
</Button>
</form>
</Flex>
);
}
return (
<Flex column>
<form onSubmit={handleSubmit}>
<HelpText>
<Trans
defaults="Creating a template from <em>{{titleWithDefault}}</em> is a non-destructive action we'll make a copy of the document and turn it into a template that can be used as a starting point for new documents."
values={{ titleWithDefault: document.titleWithDefault }}
components={{ em: <strong /> }}
/>
</HelpText>
<Button type="submit">
{isSaving ? `${t("Creating")}` : t("Create template")}
</Button>
</form>
</Flex>
);
}
export default inject("ui")(withRouter(DocumentTemplatize));
export default observer(DocumentTemplatize);
+35 -37
View File
@@ -1,59 +1,57 @@
// @flow
import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import { observer } from "mobx-react";
import * as React from "react";
import { withRouter, type RouterHistory } from "react-router-dom";
import { useTranslation, Trans } from "react-i18next";
import { useHistory } from "react-router-dom";
import { groupSettings } from "shared/utils/routeHelpers";
import UiStore from "stores/UiStore";
import Group from "models/Group";
import Button from "components/Button";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
import useToasts from "hooks/useToasts";
type Props = {
history: RouterHistory,
type Props = {|
group: Group,
ui: UiStore,
onSubmit: () => void,
};
|};
@observer
class GroupDelete extends React.Component<Props> {
@observable isDeleting: boolean;
function GroupDelete({ group, onSubmit }: Props) {
const { t } = useTranslation();
const { showToast } = useToasts();
const history = useHistory();
const [isDeleting, setIsDeleting] = React.useState();
handleSubmit = async (ev: SyntheticEvent<>) => {
const handleSubmit = async (ev: SyntheticEvent<>) => {
ev.preventDefault();
this.isDeleting = true;
setIsDeleting(true);
try {
await this.props.group.delete();
this.props.history.push(groupSettings());
this.props.onSubmit();
await group.delete();
history.push(groupSettings());
onSubmit();
} catch (err) {
this.props.ui.showToast(err.message, { type: "error" });
showToast(err.message, { type: "error" });
} finally {
this.isDeleting = false;
setIsDeleting(false);
}
};
render() {
const { group } = this.props;
return (
<Flex column>
<form onSubmit={this.handleSubmit}>
<HelpText>
Are you sure about that? Deleting the <strong>{group.name}</strong>{" "}
group will cause its members to lose access to collections and
documents that it is associated with.
</HelpText>
<Button type="submit" danger>
{this.isDeleting ? "Deleting…" : "Im sure  Delete"}
</Button>
</form>
</Flex>
);
}
return (
<Flex column>
<form onSubmit={handleSubmit}>
<HelpText>
<Trans
defaults="Are you sure about that? Deleting the <em>{{groupName}}</em> group will cause its members to lose access to collections and documents that it is associated with."
values={{ groupName: group.name }}
components={{ em: <strong /> }}
/>
</HelpText>
<Button type="submit" danger>
{isDeleting ? `${t("Deleting")}` : t("Im sure  Delete")}
</Button>
</form>
</Flex>
);
}
export default inject("ui")(withRouter(GroupDelete));
export default observer(GroupDelete);
+49 -48
View File
@@ -1,70 +1,71 @@
// @flow
import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import { observer } from "mobx-react";
import * as React from "react";
import { withRouter, type RouterHistory } from "react-router-dom";
import UiStore from "stores/UiStore";
import { useTranslation, Trans } from "react-i18next";
import Group from "models/Group";
import Button from "components/Button";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
import Input from "components/Input";
import useToasts from "hooks/useToasts";
type Props = {
history: RouterHistory,
ui: UiStore,
group: Group,
onSubmit: () => void,
};
@observer
class GroupEdit extends React.Component<Props> {
@observable name: string = this.props.group.name;
@observable isSaving: boolean;
function GroupEdit({ group, onSubmit }: Props) {
const { showToast } = useToasts();
const { t } = useTranslation();
const [name, setName] = React.useState(group.name);
const [isSaving, setIsSaving] = React.useState();
handleSubmit = async (ev: SyntheticEvent<>) => {
ev.preventDefault();
this.isSaving = true;
const handleSubmit = React.useCallback(
async (ev: SyntheticEvent<>) => {
ev.preventDefault();
setIsSaving(true);
try {
await this.props.group.save({ name: this.name });
this.props.onSubmit();
} catch (err) {
this.props.ui.showToast(err.message, { type: "error" });
} finally {
this.isSaving = false;
}
};
try {
await group.save({ name: name });
onSubmit();
} catch (err) {
showToast(err.message, { type: "error" });
} finally {
setIsSaving(false);
}
},
[group, onSubmit, showToast, name]
);
handleNameChange = (ev: SyntheticInputEvent<*>) => {
this.name = ev.target.value;
};
const handleNameChange = React.useCallback((ev: SyntheticInputEvent<*>) => {
setName(ev.target.value);
}, []);
render() {
return (
<form onSubmit={this.handleSubmit}>
<HelpText>
return (
<form onSubmit={handleSubmit}>
<HelpText>
<Trans>
You can edit the name of this group at any time, however doing so too
often might confuse your team mates.
</HelpText>
<Flex>
<Input
type="text"
label="Name"
onChange={this.handleNameChange}
value={this.name}
required
autoFocus
flex
/>
</Flex>
</Trans>
</HelpText>
<Flex>
<Input
type="text"
label={t("Name")}
onChange={handleNameChange}
value={name}
required
autoFocus
flex
/>
</Flex>
<Button type="submit" disabled={this.isSaving || !this.name}>
{this.isSaving ? "Saving" : "Save"}
</Button>
</form>
);
}
<Button type="submit" disabled={isSaving || !name}>
{isSaving ? `${t("Saving")}` : t("Save")}
</Button>
</form>
);
}
export default inject("ui")(withRouter(GroupEdit));
export default observer(GroupEdit);
+5 -5
View File
@@ -6,7 +6,7 @@ import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import AuthStore from "stores/AuthStore";
import GroupMembershipsStore from "stores/GroupMembershipsStore";
import UiStore from "stores/UiStore";
import ToastsStore from "stores/ToastsStore";
import UsersStore from "stores/UsersStore";
import Group from "models/Group";
import User from "models/User";
@@ -21,7 +21,7 @@ import PaginatedList from "components/PaginatedList";
import GroupMemberListItem from "./components/GroupMemberListItem";
type Props = {
ui: UiStore,
toasts: ToastsStore,
auth: AuthStore,
group: Group,
groupMemberships: GroupMembershipsStore,
@@ -62,12 +62,12 @@ class AddPeopleToGroup extends React.Component<Props> {
groupId: this.props.group.id,
userId: user.id,
});
this.props.ui.showToast(
this.props.toasts.showToast(
t(`{{userName}} was added to the group`, { userName: user.name }),
{ type: "success" }
);
} catch (err) {
this.props.ui.showToast(t("Could not add user"), { type: "error" });
this.props.toasts.showToast(t("Could not add user"), { type: "error" });
}
};
@@ -128,5 +128,5 @@ class AddPeopleToGroup extends React.Component<Props> {
}
export default withTranslation()<AddPeopleToGroup>(
inject("auth", "users", "groupMemberships", "ui")(AddPeopleToGroup)
inject("auth", "users", "groupMemberships", "toasts")(AddPeopleToGroup)
);
+76 -93
View File
@@ -1,14 +1,8 @@
// @flow
import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import AuthStore from "stores/AuthStore";
import GroupMembershipsStore from "stores/GroupMembershipsStore";
import PoliciesStore from "stores/PoliciesStore";
import UiStore from "stores/UiStore";
import UsersStore from "stores/UsersStore";
import { useTranslation, Trans } from "react-i18next";
import Group from "models/Group";
import User from "models/User";
import Button from "components/Button";
@@ -20,112 +14,101 @@ import PaginatedList from "components/PaginatedList";
import Subheading from "components/Subheading";
import AddPeopleToGroup from "./AddPeopleToGroup";
import GroupMemberListItem from "./components/GroupMemberListItem";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
type Props = {
ui: UiStore,
auth: AuthStore,
group: Group,
users: UsersStore,
policies: PoliciesStore,
groupMemberships: GroupMembershipsStore,
t: TFunction,
};
@observer
class GroupMembers extends React.Component<Props> {
@observable addModalOpen: boolean = false;
function GroupMembers({ group }: Props) {
const [addModalOpen, setAddModalOpen] = React.useState();
const { users, groupMemberships, policies } = useStores();
const { showToast } = useToasts();
const { t } = useTranslation();
const can = policies.abilities(group.id);
handleAddModalOpen = () => {
this.addModalOpen = true;
const handleAddModal = (state) => {
setAddModalOpen(state);
};
handleAddModalClose = () => {
this.addModalOpen = false;
};
handleRemoveUser = async (user: User) => {
const { t } = this.props;
const handleRemoveUser = async (user: User) => {
try {
await this.props.groupMemberships.delete({
groupId: this.props.group.id,
await groupMemberships.delete({
groupId: group.id,
userId: user.id,
});
this.props.ui.showToast(
showToast(
t(`{{userName}} was removed from the group`, { userName: user.name }),
{ type: "success" }
);
} catch (err) {
this.props.ui.showToast(t("Could not remove user"), { type: "error" });
showToast(t("Could not remove user"), { type: "error" });
}
};
render() {
const { group, users, groupMemberships, policies, t, auth } = this.props;
const { user } = auth;
if (!user) return null;
const can = policies.abilities(group.id);
return (
<Flex column>
{can.update ? (
<>
<HelpText>
Add and remove team members in the <strong>{group.name}</strong>{" "}
group. Adding people to the group will give them access to any
collections this group has been added to.
</HelpText>
<span>
<Button
type="button"
onClick={this.handleAddModalOpen}
icon={<PlusIcon />}
neutral
>
{t("Add people")}
</Button>
</span>
</>
) : (
return (
<Flex column>
{can.update ? (
<>
<HelpText>
Listing team members in the <strong>{group.name}</strong> group.
<Trans
defaults="Add and remove team members in the <em>{{groupName}}</em> group. Adding people to the group will give them access to any collections this group has been added to."
values={{ groupName: group.name }}
components={{ em: <strong /> }}
/>
</HelpText>
)}
<span>
<Button
type="button"
onClick={() => handleAddModal(true)}
icon={<PlusIcon />}
neutral
>
{t("Add people")}
</Button>
</span>
</>
) : (
<HelpText>
<Trans
defaults="Listing team members in the <em>{{groupName}}</em> group."
values={{ groupName: group.name }}
components={{ em: <strong /> }}
/>
</HelpText>
)}
<Subheading>Members</Subheading>
<PaginatedList
items={users.inGroup(group.id)}
fetch={groupMemberships.fetchPage}
options={{ id: group.id }}
empty={<Empty>{t("This group has no members.")}</Empty>}
renderItem={(item) => (
<GroupMemberListItem
key={item.id}
user={item}
onRemove={
can.update ? () => this.handleRemoveUser(item) : undefined
}
/>
)}
/>
{can.update && (
<Modal
title={`Add people to ${group.name}`}
onRequestClose={this.handleAddModalClose}
isOpen={this.addModalOpen}
>
<AddPeopleToGroup
group={group}
onSubmit={this.handleAddModalClose}
/>
</Modal>
<Subheading>
<Trans>Members</Trans>
</Subheading>
<PaginatedList
items={users.inGroup(group.id)}
fetch={groupMemberships.fetchPage}
options={{ id: group.id }}
empty={<Empty>{t("This group has no members.")}</Empty>}
renderItem={(item) => (
<GroupMemberListItem
key={item.id}
user={item}
onRemove={can.update ? () => handleRemoveUser(item) : undefined}
/>
)}
</Flex>
);
}
/>
{can.update && (
<Modal
title={t(`Add people to {{groupName}}`, { groupName: group.name })}
onRequestClose={() => handleAddModal(false)}
isOpen={addModalOpen}
>
<AddPeopleToGroup
group={group}
onSubmit={() => handleAddModal(false)}
/>
</Modal>
)}
</Flex>
);
}
export default withTranslation()<GroupMembers>(
inject("auth", "users", "policies", "groupMemberships", "ui")(GroupMembers)
);
export default observer(GroupMembers);
+55 -55
View File
@@ -1,10 +1,7 @@
// @flow
import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import { observer } from "mobx-react";
import * as React from "react";
import { withRouter, type RouterHistory } from "react-router-dom";
import GroupsStore from "stores/GroupsStore";
import UiStore from "stores/UiStore";
import { useTranslation, Trans } from "react-i18next";
import Group from "models/Group";
import GroupMembers from "scenes/GroupMembers";
import Button from "components/Button";
@@ -12,79 +9,82 @@ import Flex from "components/Flex";
import HelpText from "components/HelpText";
import Input from "components/Input";
import Modal from "components/Modal";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
type Props = {
history: RouterHistory,
ui: UiStore,
groups: GroupsStore,
onSubmit: () => void,
};
@observer
class GroupNew extends React.Component<Props> {
@observable name: string = "";
@observable isSaving: boolean;
@observable group: Group;
function GroupNew({ onSubmit }: Props) {
const { groups } = useStores();
const { t } = useTranslation();
const { showToast } = useToasts();
const [name, setName] = React.useState();
const [isSaving, setIsSaving] = React.useState();
const [group, setGroup] = React.useState();
handleSubmit = async (ev: SyntheticEvent<>) => {
const handleSubmit = async (ev: SyntheticEvent<>) => {
ev.preventDefault();
this.isSaving = true;
setIsSaving(true);
const group = new Group(
{
name: this.name,
name: name,
},
this.props.groups
groups
);
try {
this.group = await group.save();
setGroup(await group.save());
} catch (err) {
this.props.ui.showToast(err.message, { type: "error" });
showToast(err.message, { type: "error" });
} finally {
this.isSaving = false;
setIsSaving(false);
}
};
handleNameChange = (ev: SyntheticInputEvent<*>) => {
this.name = ev.target.value;
const handleNameChange = (ev: SyntheticInputEvent<*>) => {
setName(ev.target.value);
};
render() {
return (
<>
<form onSubmit={this.handleSubmit}>
<HelpText>
return (
<>
<form onSubmit={handleSubmit}>
<HelpText>
<Trans>
Groups are for organizing your team. They work best when centered
around a function or a responsibility Support or Engineering for
example.
</HelpText>
<Flex>
<Input
type="text"
label="Name"
onChange={this.handleNameChange}
value={this.name}
required
autoFocus
flex
/>
</Flex>
<HelpText>Youll be able to add people to the group next.</HelpText>
</Trans>
</HelpText>
<Flex>
<Input
type="text"
label="Name"
onChange={handleNameChange}
value={name}
required
autoFocus
flex
/>
</Flex>
<HelpText>
<Trans>Youll be able to add people to the group next.</Trans>
</HelpText>
<Button type="submit" disabled={this.isSaving || !this.name}>
{this.isSaving ? "Creating" : "Continue"}
</Button>
</form>
<Modal
title="Group members"
onRequestClose={this.props.onSubmit}
isOpen={!!this.group}
>
<GroupMembers group={this.group} />
</Modal>
</>
);
}
<Button type="submit" disabled={isSaving || !name}>
{isSaving ? `${t("Creating")}` : t("Continue")}
</Button>
</form>
<Modal
title={t("Group members")}
onRequestClose={onSubmit}
isOpen={!!group}
>
<GroupMembers group={group} />
</Modal>
</>
);
}
export default inject("groups", "ui")(withRouter(GroupNew));
export default observer(GroupNew);
+17 -7
View File
@@ -5,6 +5,7 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { Switch, Route } from "react-router-dom";
import { Action } from "components/Actions";
import Empty from "components/Empty";
import Heading from "components/Heading";
import InputSearchPage from "components/InputSearchPage";
import LanguagePrompt from "components/LanguagePrompt";
@@ -41,19 +42,19 @@ function Home() {
<Heading>{t("Home")}</Heading>
<Tabs>
<Tab to="/home" exact>
{t("Recently updated")}
{t("Recently viewed")}
</Tab>
<Tab to="/home/recent" exact>
{t("Recently viewed")}
{t("Recently updated")}
</Tab>
<Tab to="/home/created">{t("Created by me")}</Tab>
</Tabs>
<Switch>
<Route path="/home/recent">
<PaginatedDocumentList
key="recent"
documents={documents.recentlyViewed}
fetch={documents.fetchRecentlyViewed}
documents={documents.recentlyUpdated}
fetch={documents.fetchRecentlyUpdated}
empty={<Empty>{t("Weird, this shouldnt ever be empty")}</Empty>}
showCollection
/>
</Route>
@@ -63,13 +64,22 @@ function Home() {
documents={documents.createdByUser(user)}
fetch={documents.fetchOwned}
options={{ user }}
empty={<Empty>{t("Weird, this shouldnt ever be empty")}</Empty>}
showCollection
/>
</Route>
<Route path="/home">
<PaginatedDocumentList
documents={documents.recentlyUpdated}
fetch={documents.fetchRecentlyUpdated}
key="recent"
documents={documents.recentlyViewed}
fetch={documents.fetchRecentlyViewed}
empty={
<Empty>
{t(
"Documents youve recently viewed will be here for easy access"
)}
</Empty>
}
showCollection
/>
</Route>
+176 -166
View File
@@ -1,14 +1,10 @@
// @flow
import { observable, action } from "mobx";
import { inject, observer } from "mobx-react";
import { observer } from "mobx-react";
import { LinkIcon, CloseIcon } from "outline-icons";
import * as React from "react";
import { Link, withRouter, type RouterHistory } from "react-router-dom";
import { useTranslation, Trans } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
import AuthStore from "stores/AuthStore";
import PoliciesStore from "stores/PoliciesStore";
import UiStore from "stores/UiStore";
import UsersStore from "stores/UsersStore";
import Button from "components/Button";
import CopyToClipboard from "components/CopyToClipboard";
import Flex from "components/Flex";
@@ -16,198 +12,212 @@ import HelpText from "components/HelpText";
import Input from "components/Input";
import NudeButton from "components/NudeButton";
import Tooltip from "components/Tooltip";
import useCurrentTeam from "hooks/useCurrentTeam";
import useCurrentUser from "hooks/useCurrentUser";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
const MAX_INVITES = 20;
type Props = {
auth: AuthStore,
users: UsersStore,
history: RouterHistory,
policies: PoliciesStore,
ui: UiStore,
type Props = {|
onSubmit: () => void,
};
|};
type InviteRequest = {
email: string,
name: string,
};
@observer
class Invite extends React.Component<Props> {
@observable isSaving: boolean;
@observable linkCopied: boolean = false;
@observable
invites: InviteRequest[] = [
function Invite({ onSubmit }: Props) {
const [isSaving, setIsSaving] = React.useState();
const [linkCopied, setLinkCopied] = React.useState<boolean>(false);
const [invites, setInvites] = React.useState<InviteRequest[]>([
{ email: "", name: "" },
{ email: "", name: "" },
{ email: "", name: "" },
];
]);
handleSubmit = async (ev: SyntheticEvent<>) => {
ev.preventDefault();
this.isSaving = true;
const { users, policies } = useStores();
const { showToast } = useToasts();
const user = useCurrentUser();
const team = useCurrentTeam();
const { t } = useTranslation();
try {
await this.props.users.invite(this.invites);
this.props.onSubmit();
this.props.ui.showToast("We sent out your invites!", { type: "success" });
} catch (err) {
this.props.ui.showToast(err.message, { type: "error" });
} finally {
this.isSaving = false;
}
};
const predictedDomain = user.email.split("@")[1];
const can = policies.abilities(team.id);
@action
handleChange = (ev, index) => {
this.invites[index][ev.target.name] = ev.target.value;
};
const handleSubmit = React.useCallback(
async (ev: SyntheticEvent<>) => {
ev.preventDefault();
setIsSaving(true);
@action
handleGuestChange = (ev, index) => {
this.invites[index][ev.target.name] = ev.target.checked;
};
try {
await users.invite(invites);
onSubmit();
showToast(t("We sent out your invites!"), { type: "success" });
} catch (err) {
showToast(err.message, { type: "error" });
} finally {
setIsSaving(false);
}
},
[onSubmit, showToast, invites, t, users]
);
@action
handleAdd = () => {
if (this.invites.length >= MAX_INVITES) {
this.props.ui.showToast(
`Sorry, you can only send ${MAX_INVITES} invites at a time`,
const handleChange = React.useCallback((ev, index) => {
setInvites((prevInvites) => {
const newInvites = [...prevInvites];
newInvites[index][ev.target.name] = ev.target.value;
return newInvites;
});
}, []);
const handleAdd = React.useCallback(() => {
if (invites.length >= MAX_INVITES) {
showToast(
t("Sorry, you can only send {{MAX_INVITES}} invites at a time", {
MAX_INVITES,
}),
{ type: "warning" }
);
}
this.invites.push({ email: "", name: "" });
};
setInvites((prevInvites) => {
const newInvites = [...prevInvites];
newInvites.push({ email: "", name: "" });
return newInvites;
});
}, [showToast, invites, t]);
@action
handleRemove = (ev: SyntheticEvent<>, index: number) => {
ev.preventDefault();
this.invites.splice(index, 1);
};
const handleRemove = React.useCallback(
(ev: SyntheticEvent<>, index: number) => {
ev.preventDefault();
handleCopy = () => {
this.linkCopied = true;
this.props.ui.showToast("Share link copied", {
setInvites((prevInvites) => {
const newInvites = [...prevInvites];
newInvites.splice(index, 1);
return newInvites;
});
},
[]
);
const handleCopy = React.useCallback(() => {
setLinkCopied(true);
showToast(t("Share link copied"), {
type: "success",
});
};
}, [showToast, t]);
render() {
const { team, user } = this.props.auth;
if (!team || !user) return null;
const predictedDomain = user.email.split("@")[1];
const can = this.props.policies.abilities(team.id);
return (
<form onSubmit={this.handleSubmit}>
{team.guestSignin ? (
<HelpText>
Invite team members or guests to join your knowledge base. Team
members can sign in with {team.signinMethods} or use their email
address.
</HelpText>
) : (
<HelpText>
Invite team members to join your knowledge base. They will need to
sign in with {team.signinMethods}.{" "}
{can.update && (
<>
As an admin you can also{" "}
<Link to="/settings/security">enable email sign-in</Link>.
</>
)}
</HelpText>
)}
{team.subdomain && (
<CopyBlock>
<Flex align="flex-end">
<Input
type="text"
value={team.url}
label="Want a link to share directly with your team?"
readOnly
flex
/>
&nbsp;&nbsp;
<CopyToClipboard text={team.url} onCopy={this.handleCopy}>
<Button
type="button"
icon={<LinkIcon />}
style={{ marginBottom: "16px" }}
neutral
>
{this.linkCopied ? "Link copied" : "Copy link"}
</Button>
</CopyToClipboard>
</Flex>
<p>
<hr />
</p>
</CopyBlock>
)}
{this.invites.map((invite, index) => (
<Flex key={index}>
return (
<form onSubmit={handleSubmit}>
{team.guestSignin ? (
<HelpText>
<Trans
defaults="Invite team members or guests to join your knowledge base. Team members can sign in with {{signinMethods}} or use their email address."
values={{ signinMethods: team.signinMethods }}
/>
</HelpText>
) : (
<HelpText>
<Trans
defaults="Invite team members to join your knowledge base. They will need to sign in with {{signinMethods}}."
values={{ signinMethods: team.signinMethods }}
/>{" "}
{can.update && (
<Trans>
As an admin you can also{" "}
<Link to="/settings/security">enable email sign-in</Link>.
</Trans>
)}
</HelpText>
)}
{team.subdomain && (
<CopyBlock>
<Flex align="flex-end">
<Input
type="email"
name="email"
label="Email"
labelHidden={index !== 0}
onChange={(ev) => this.handleChange(ev, index)}
placeholder={`example@${predictedDomain}`}
value={invite.email}
required={index === 0}
autoFocus={index === 0}
type="text"
value={team.url}
label={t("Want a link to share directly with your team?")}
readOnly
flex
/>
&nbsp;&nbsp;
<Input
type="text"
name="name"
label="Full name"
labelHidden={index !== 0}
onChange={(ev) => this.handleChange(ev, index)}
value={invite.name}
required={!!invite.email}
flex
/>
{index !== 0 && (
<Remove>
<Tooltip tooltip="Remove invite" placement="top">
<NudeButton onClick={(ev) => this.handleRemove(ev, index)}>
<CloseIcon />
</NudeButton>
</Tooltip>
</Remove>
)}
<CopyToClipboard text={team.url} onCopy={handleCopy}>
<Button
type="button"
icon={<LinkIcon />}
style={{ marginBottom: "16px" }}
neutral
>
{linkCopied ? t("Link copied") : t("Copy link")}
</Button>
</CopyToClipboard>
</Flex>
))}
<Flex justify="space-between">
{this.invites.length <= MAX_INVITES ? (
<Button type="button" onClick={this.handleAdd} neutral>
Add another
</Button>
) : (
<span />
<p>
<hr />
</p>
</CopyBlock>
)}
{invites.map((invite, index) => (
<Flex key={index}>
<Input
type="email"
name="email"
label={t("Email")}
labelHidden={index !== 0}
onChange={(ev) => handleChange(ev, index)}
placeholder={`example@${predictedDomain}`}
value={invite.email}
required={index === 0}
autoFocus={index === 0}
flex
/>
&nbsp;&nbsp;
<Input
type="text"
name="name"
label={t("Full name")}
labelHidden={index !== 0}
onChange={(ev) => handleChange(ev, index)}
value={invite.name}
required={!!invite.email}
flex
/>
{index !== 0 && (
<Remove>
<Tooltip tooltip={t("Remove invite")} placement="top">
<NudeButton onClick={(ev) => handleRemove(ev, index)}>
<CloseIcon />
</NudeButton>
</Tooltip>
</Remove>
)}
<Button
type="submit"
disabled={this.isSaving}
data-on="click"
data-event-category="invite"
data-event-action="sendInvites"
>
{this.isSaving ? "Inviting…" : "Send Invites"}
</Button>
</Flex>
<br />
</form>
);
}
))}
<Flex justify="space-between">
{invites.length <= MAX_INVITES ? (
<Button type="button" onClick={handleAdd} neutral>
<Trans>Add another</Trans>
</Button>
) : (
<span />
)}
<Button
type="submit"
disabled={isSaving}
data-on="click"
data-event-category="invite"
data-event-action="sendInvites"
>
{isSaving ? `${t("Inviting")}` : t("Send Invites")}
</Button>
</Flex>
<br />
</form>
);
}
const CopyBlock = styled("div")`
@@ -221,4 +231,4 @@ const Remove = styled("div")`
right: -32px;
`;
export default inject("auth", "users", "policies", "ui")(withRouter(Invite));
export default observer(Invite);
+9 -5
View File
@@ -1,6 +1,7 @@
// @flow
import { EmailIcon } from "outline-icons";
import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import styled from "styled-components";
import AuthLogo from "components/AuthLogo";
import ButtonLarge from "components/ButtonLarge";
@@ -13,6 +14,7 @@ type Props = {
authUrl: string,
isCreate: boolean,
onEmailSuccess: (email: string) => void,
t: TFunction,
};
type State = {
@@ -56,7 +58,7 @@ class Provider extends React.Component<Props, State> {
};
render() {
const { isCreate, id, name, authUrl } = this.props;
const { isCreate, id, name, authUrl, t } = this.props;
if (id === "email") {
if (isCreate) {
@@ -84,12 +86,12 @@ class Provider extends React.Component<Props, State> {
short
/>
<ButtonLarge type="submit" disabled={this.state.isSubmitting}>
Sign In
{t("Sign In")}
</ButtonLarge>
</>
) : (
<ButtonLarge type="submit" icon={<EmailIcon />} fullwidth>
Continue with Email
{t("Continue with Email")}
</ButtonLarge>
)}
</Form>
@@ -104,7 +106,9 @@ class Provider extends React.Component<Props, State> {
icon={<AuthLogo providerName={id} />}
fullwidth
>
Continue with {name}
{t("Continue with {{ authProviderName }}", {
authProviderName: name,
})}
</ButtonLarge>
</Wrapper>
);
@@ -122,4 +126,4 @@ const Form = styled.form`
justify-content: space-between;
`;
export default Provider;
export default withTranslation()<Provider>(Provider);

Some files were not shown because too many files have changed in this diff Show More