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:
@@ -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<any>;
|
||||
onFocus?: (ev: React.SyntheticEvent) => unknown;
|
||||
@@ -155,6 +157,7 @@ class Input extends React.Component<Props> {
|
||||
icon,
|
||||
label,
|
||||
margin,
|
||||
error,
|
||||
className,
|
||||
short,
|
||||
flex,
|
||||
@@ -197,11 +200,26 @@ class Input extends React.Component<Props> {
|
||||
)}
|
||||
</Outline>
|
||||
</label>
|
||||
<TextWrapper>
|
||||
<StyledText type="danger" size="xsmall">
|
||||
{error}
|
||||
</StyledText>
|
||||
</TextWrapper>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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<Props, "innerRef">, ref: React.Ref<any>) => {
|
||||
return <Input {...{ ...props, innerRef: ref }} />;
|
||||
|
||||
@@ -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>`
|
||||
? 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"
|
||||
|
||||
@@ -20,6 +20,10 @@ class Share extends BaseModel {
|
||||
@observable
|
||||
documentId: string;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
urlId: string;
|
||||
|
||||
documentTitle: string;
|
||||
|
||||
documentUrl: string;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user