diff --git a/app/components/Input.tsx b/app/components/Input.tsx index 44004c1f2..d0ecd6d48 100644 --- a/app/components/Input.tsx +++ b/app/components/Input.tsx @@ -5,6 +5,7 @@ import { VisuallyHidden } from "reakit/VisuallyHidden"; import styled from "styled-components"; import breakpoint from "styled-components-breakpoint"; import Flex from "~/components/Flex"; +import Text from "~/components/Text"; import { undraggableOnDesktop } from "~/styles"; const RealTextarea = styled.textarea<{ hasIcon?: boolean }>` @@ -120,6 +121,7 @@ export type Props = React.InputHTMLAttributes< flex?: boolean; short?: boolean; margin?: string | number; + error?: string; icon?: React.ReactNode; innerRef?: React.Ref; onFocus?: (ev: React.SyntheticEvent) => unknown; @@ -155,6 +157,7 @@ class Input extends React.Component { icon, label, margin, + error, className, short, flex, @@ -197,11 +200,26 @@ class Input extends React.Component { )} + + + {error} + + ); } } +export const TextWrapper = styled.span` + min-height: 16px; + display: block; + margin-top: -16px; +`; + +export const StyledText = styled(Text)` + margin-bottom: 0; +`; + export const ReactHookWrappedInput = React.forwardRef( (props: Omit, ref: React.Ref) => { return ; diff --git a/app/components/Text.ts b/app/components/Text.ts index 2e8d73eba..e328d1b9c 100644 --- a/app/components/Text.ts +++ b/app/components/Text.ts @@ -1,7 +1,7 @@ import styled from "styled-components"; type Props = { - type?: "secondary" | "tertiary"; + type?: "secondary" | "tertiary" | "danger"; size?: "large" | "small" | "xsmall"; }; @@ -16,6 +16,8 @@ const Text = styled.p` ? props.theme.textSecondary : props.type === "tertiary" ? props.theme.textTertiary + : props.type === "danger" + ? props.theme.brand.red : props.theme.text}; font-size: ${(props) => props.size === "large" diff --git a/app/models/Share.ts b/app/models/Share.ts index ffa73e953..2533159ea 100644 --- a/app/models/Share.ts +++ b/app/models/Share.ts @@ -20,6 +20,10 @@ class Share extends BaseModel { @observable documentId: string; + @Field + @observable + urlId: string; + documentTitle: string; documentUrl: string; diff --git a/app/scenes/Document/components/SharePopover.tsx b/app/scenes/Document/components/SharePopover.tsx index fa01b6a89..d64a3f56e 100644 --- a/app/scenes/Document/components/SharePopover.tsx +++ b/app/scenes/Document/components/SharePopover.tsx @@ -1,15 +1,21 @@ import { formatDistanceToNow } from "date-fns"; import invariant from "invariant"; +import { debounce, isEmpty } from "lodash"; import { observer } from "mobx-react"; import { ExpandedIcon, GlobeIcon, PadlockIcon } from "outline-icons"; import * as React from "react"; import { useTranslation, Trans } from "react-i18next"; import styled from "styled-components"; +import { SHARE_URL_SLUG_REGEX } from "@shared/utils/urlHelpers"; import Document from "~/models/Document"; import Share from "~/models/Share"; import Button from "~/components/Button"; import CopyToClipboard from "~/components/CopyToClipboard"; import Flex from "~/components/Flex"; +import Input, { + TextWrapper, + StyledText as DocumentLinkPreview, +} from "~/components/Input"; import Notice from "~/components/Notice"; import Switch from "~/components/Switch"; import Text from "~/components/Text"; @@ -40,9 +46,10 @@ function SharePopover({ const { t } = useTranslation(); const { shares } = useStores(); const { showToast } = useToasts(); - const [isCopied, setIsCopied] = React.useState(false); const [expandedOptions, setExpandedOptions] = React.useState(false); const [isEditMode, setIsEditMode] = React.useState(false); + const [slugValidationError, setSlugValidationError] = React.useState(""); + const [urlSlug, setUrlSlug] = React.useState(""); const timeout = React.useRef>(); const buttonRef = React.useRef(null); const can = usePolicy(share ? share.id : ""); @@ -73,6 +80,13 @@ function SharePopover({ return () => (timeout.current ? clearTimeout(timeout.current) : undefined); }, [document, visible, team.sharing]); + React.useEffect(() => { + if (!visible) { + setUrlSlug(share?.urlId || ""); + setSlugValidationError(""); + } + }, [share, visible]); + const handlePublishedChange = React.useCallback( async (event) => { const share = shares.getByDocumentId(document.id); @@ -110,9 +124,7 @@ function SharePopover({ ); const handleCopied = React.useCallback(() => { - setIsCopied(true); timeout.current = setTimeout(() => { - setIsCopied(false); onRequestClose(); showToast(t("Share link copied"), { type: "info", @@ -120,6 +132,38 @@ function SharePopover({ }, 250); }, [t, onRequestClose, showToast]); + const handleUrlSlugChange = React.useMemo( + () => + debounce(async (ev) => { + const share = shares.getByDocumentId(document.id); + invariant(share, "Share must exist"); + + const val = ev.target.value; + setUrlSlug(val); + if (val && !SHARE_URL_SLUG_REGEX.test(val)) { + setSlugValidationError( + t("Only lowercase letters, digits and dashes allowed") + ); + } else { + setSlugValidationError(""); + if (share.urlId !== val) { + try { + await share.save({ + urlId: isEmpty(val) ? null : val, + }); + } catch (err) { + if (err.message.includes("must be unique")) { + setSlugValidationError( + t("Sorry, this link has already been used") + ); + } + } + } + } + }, 500), + [t, document.id, shares] + ); + const userLocale = useUserLocale(); const locale = userLocale ? dateLocale(userLocale) : undefined; let shareUrl = team.sharing ? share?.url ?? "" : `${team.url}${document.url}`; @@ -211,6 +255,31 @@ function SharePopover({ {expandedOptions && ( <> + + + + {!slugValidationError && urlSlug && ( + + + The document will be available at +
+ + {urlSlug ? `${team.url}/s/${urlSlug}` : ""} + +
+
+ )} +