Ability to create share url slug (#4550)

* feat: share url slug

* feat: add col urlId

* feat: allow updating urlId

* fix: typo

* fix: migrations

* fix: urlId model validation

* fix: input label

* fix: debounce slug request

* feat: link preview

* fix: send slug variant in response if available

* fix: temporary redirect to slug variant if available

* fix: move up the custom link field

* fix: process and display backend err

* fix: reset custom link state on popover close and remove isCopied

* fix: document link preview

* fix: set urlId when available

* fix: keep unique(urlId, teamId)

* fix: codeql

* fix: get rid of preview type

* fix: width not needed for block elem

* fix: migrations

* fix: array not required

* fix: use val

* fix: validation on shareId and test

* fix: allow clearing urlId

* fix: do not escape

* fix: unique error text

* fix: keep team
This commit is contained in:
Apoorv Mishra
2022-12-14 06:56:36 +05:30
committed by GitHub
parent b9dd060736
commit 79829a3129
16 changed files with 288 additions and 14 deletions

View File

@@ -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<ReturnType<typeof setTimeout>>();
const buttonRef = React.useRef<HTMLButtonElement>(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 && (
<>
<Separator />
<SwitchWrapper>
<Input
type="text"
label={t("Custom link")}
onChange={handleUrlSlugChange}
error={slugValidationError}
defaultValue={urlSlug}
/>
{!slugValidationError && urlSlug && (
<DocumentLinkPreviewWrapper>
<DocumentLinkPreview type="secondary" size="small">
<Trans>The document will be available at</Trans>
<br />
<a
href={urlSlug ? `${team.url}/s/${urlSlug}` : ""}
target="_blank"
rel="noopener noreferrer"
>
{urlSlug ? `${team.url}/s/${urlSlug}` : ""}
</a>
</DocumentLinkPreview>
</DocumentLinkPreviewWrapper>
)}
</SwitchWrapper>
<Separator />
<SwitchWrapper>
<Switch
@@ -252,7 +321,7 @@ function SharePopover({
<CopyToClipboard text={shareUrl} onCopy={handleCopied}>
<Button
type="submit"
disabled={isCopied || (!share && team.sharing)}
disabled={(!share && team.sharing) || slugValidationError}
ref={buttonRef}
>
{t("Copy link")}
@@ -301,4 +370,8 @@ const SwitchText = styled(Text)`
font-size: 15px;
`;
const DocumentLinkPreviewWrapper = styled(TextWrapper)`
margin-top: -12px;
`;
export default observer(SharePopover);