mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-09 22:14:29 +09:00
Fixes https://github.com/webstudio-is/webstudio/issues/3776 ## Description During renaming of the token - when token name is less than 1 character - show error state - when token name already exists - show error state Fixed a bunch of bugs with esc, error handling and switching to editing another token while still editing the first one https://github.com/user-attachments/assets/5833e611-235b-4c2c-a3ed-3ee521ac5fd1 ## Steps for reproduction 1. click button 2. expect xyz ## Code Review - [ ] hi @kof, I need you to do - conceptual review (architecture, feature-correctness) - detailed review (read every line) - test it on preview ## Before requesting a review - [ ] made a self-review - [ ] added inline comments where things may be not obvious (the "why", not "what") ## Before merging - [ ] tested locally and on preview environment (preview dev login: 5de6) - [ ] updated [test cases](https://github.com/webstudio-is/webstudio/blob/main/apps/builder/docs/test-cases.md) document - [ ] added tests - [ ] if any new env variables are added, added them to `.env` file
444 lines
11 KiB
TypeScript
444 lines
11 KiB
TypeScript
import {
|
|
Box,
|
|
Button,
|
|
css,
|
|
Flex,
|
|
Label,
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
rawTheme,
|
|
Separator,
|
|
Switch,
|
|
theme,
|
|
Tooltip,
|
|
useId,
|
|
Collapsible,
|
|
keyframes,
|
|
Text,
|
|
InputField,
|
|
Link,
|
|
buttonStyle,
|
|
IconButton,
|
|
Checkbox,
|
|
Grid,
|
|
} from "@webstudio-is/design-system";
|
|
import {
|
|
CopyIcon,
|
|
EllipsesIcon,
|
|
PlusIcon,
|
|
HelpIcon,
|
|
} from "@webstudio-is/icons";
|
|
import { Fragment, useState, type ComponentProps, type ReactNode } from "react";
|
|
import { useIds } from "../form-utils";
|
|
|
|
const Item = (props: ComponentProps<typeof Flex>) => (
|
|
<Flex
|
|
direction="column"
|
|
css={{ px: theme.spacing[7], py: theme.spacing[5] }}
|
|
gap="1"
|
|
{...props}
|
|
/>
|
|
);
|
|
|
|
type PermissionProps = {
|
|
title: string;
|
|
info: ReactNode;
|
|
checked: boolean;
|
|
disabled?: boolean;
|
|
onCheckedChange: (checked: boolean) => void;
|
|
};
|
|
const Permission = ({
|
|
title,
|
|
info,
|
|
checked,
|
|
disabled = false,
|
|
onCheckedChange,
|
|
}: PermissionProps) => {
|
|
const id = useId();
|
|
|
|
const tooltipContent = (
|
|
<Flex direction="column" gap="2" css={{ maxWidth: theme.spacing[28] }}>
|
|
<Text variant="titles">{title}</Text>
|
|
<Text>{info}</Text>
|
|
</Flex>
|
|
);
|
|
|
|
return (
|
|
<Flex align="center" gap="1">
|
|
<Switch
|
|
disabled={disabled}
|
|
checked={checked}
|
|
id={id}
|
|
onCheckedChange={onCheckedChange}
|
|
/>
|
|
<Label disabled={disabled} htmlFor={id}>
|
|
{title}
|
|
</Label>
|
|
<Tooltip content={tooltipContent} variant="wrapped">
|
|
<HelpIcon color={rawTheme.colors.foregroundSubtle} tabIndex={0} />
|
|
</Tooltip>
|
|
</Flex>
|
|
);
|
|
};
|
|
|
|
type MenuProps = {
|
|
name: string;
|
|
value: LinkOptions;
|
|
hasProPlan: boolean;
|
|
onChange: (value: LinkOptions) => void;
|
|
onDelete: () => void;
|
|
};
|
|
|
|
const Menu = ({ name, hasProPlan, value, onChange, onDelete }: MenuProps) => {
|
|
const ids = useIds(["name", "canClone", "canCopy"]);
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [customLinkName, setCustomLinkName] = useState<string>(name);
|
|
|
|
const handleCheckedChange = (relation: Relation) => (checked: boolean) => {
|
|
if (checked) {
|
|
onChange({ ...value, relation });
|
|
}
|
|
};
|
|
|
|
const saveCustomLinkName = () => {
|
|
if (customLinkName.length === 0) {
|
|
return;
|
|
}
|
|
|
|
onChange({ ...value, name: customLinkName.trim() });
|
|
};
|
|
|
|
return (
|
|
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
prefix={<EllipsesIcon />}
|
|
color="ghost"
|
|
aria-label="Menu Button for options"
|
|
></Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
css={{
|
|
//padding: 0,
|
|
width: theme.spacing[24],
|
|
}}
|
|
sideOffset={0}
|
|
onInteractOutside={saveCustomLinkName}
|
|
>
|
|
<Item>
|
|
<Label htmlFor={ids.name}>Name</Label>
|
|
<InputField
|
|
id={ids.name}
|
|
color={customLinkName.length === 0 ? "error" : undefined}
|
|
value={customLinkName}
|
|
onChange={(event) => setCustomLinkName(event.target.value)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter") {
|
|
saveCustomLinkName();
|
|
setIsOpen(false);
|
|
}
|
|
}}
|
|
onBlur={saveCustomLinkName}
|
|
placeholder="Share Project"
|
|
name="Name"
|
|
autoFocus
|
|
/>
|
|
</Item>
|
|
<Separator />
|
|
<Item>
|
|
<Label>Permissions</Label>
|
|
<Permission
|
|
checked={value.relation === "viewers"}
|
|
onCheckedChange={handleCheckedChange("viewers")}
|
|
title="View"
|
|
//info="Recipients can view, copy instances and clone the project"
|
|
info={
|
|
<Flex direction="column">
|
|
Recipients can view, copy instances and clone the project.
|
|
{hasProPlan !== true && (
|
|
<>
|
|
<br />
|
|
<br />
|
|
Upgrade to a Pro account to set additional permissions.
|
|
<br /> <br />
|
|
<Link
|
|
className={buttonStyle({ color: "gradient" })}
|
|
color="contrast"
|
|
underline="none"
|
|
href="https://webstudio.is/pricing"
|
|
target="_blank"
|
|
>
|
|
Upgrade
|
|
</Link>
|
|
</>
|
|
)}
|
|
</Flex>
|
|
}
|
|
/>
|
|
|
|
<Grid
|
|
css={{
|
|
ml: theme.spacing[6],
|
|
}}
|
|
>
|
|
<Grid
|
|
gap={1}
|
|
flow={"column"}
|
|
css={{
|
|
alignItems: "center",
|
|
justifyContent: "start",
|
|
}}
|
|
>
|
|
<Checkbox
|
|
disabled={hasProPlan !== true}
|
|
checked={value.canClone}
|
|
onCheckedChange={(canClone) => {
|
|
onChange({ ...value, canClone: Boolean(canClone) });
|
|
}}
|
|
id={ids.canClone}
|
|
/>
|
|
<Label htmlFor={ids.canClone} disabled={hasProPlan !== true}>
|
|
Can clone
|
|
</Label>
|
|
</Grid>
|
|
<Grid
|
|
gap={1}
|
|
flow={"column"}
|
|
css={{
|
|
alignItems: "center",
|
|
justifyContent: "start",
|
|
}}
|
|
>
|
|
<Checkbox
|
|
disabled={hasProPlan !== true}
|
|
checked={value.canCopy}
|
|
onCheckedChange={(canCopy) => {
|
|
onChange({ ...value, canCopy: Boolean(canCopy) });
|
|
}}
|
|
id={ids.canCopy}
|
|
/>
|
|
<Label htmlFor={ids.canCopy} disabled={hasProPlan !== true}>
|
|
Can copy
|
|
</Label>
|
|
</Grid>
|
|
</Grid>
|
|
|
|
<Permission
|
|
onCheckedChange={handleCheckedChange("builders")}
|
|
checked={value.relation === "builders"}
|
|
title="Build"
|
|
info="Recipients can make any changes but can not publish the project."
|
|
/>
|
|
|
|
<Permission
|
|
disabled={hasProPlan !== true}
|
|
onCheckedChange={handleCheckedChange("administrators")}
|
|
checked={value.relation === "administrators"}
|
|
title="Admin"
|
|
info={
|
|
<Flex direction="column">
|
|
Recipients can make any changes and can also publish the
|
|
project.
|
|
{hasProPlan !== true && (
|
|
<>
|
|
<br />
|
|
<br />
|
|
Upgrade to a Pro account to share with Admin permissions.
|
|
<br /> <br />
|
|
<Link
|
|
className={buttonStyle({ color: "gradient" })}
|
|
color="contrast"
|
|
underline="none"
|
|
href="https://webstudio.is/pricing"
|
|
target="_blank"
|
|
>
|
|
Upgrade
|
|
</Link>
|
|
</>
|
|
)}
|
|
</Flex>
|
|
}
|
|
/>
|
|
</Item>
|
|
<Separator />
|
|
<Item>
|
|
{/* @todo need a menu item that looks like one from dropdown but without DropdownMenu */}
|
|
<Button
|
|
color="neutral-destructive"
|
|
onClick={() => {
|
|
onDelete();
|
|
}}
|
|
>
|
|
Delete Link
|
|
</Button>
|
|
</Item>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
};
|
|
|
|
const itemStyle = css({
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: theme.spacing[3],
|
|
py: theme.spacing[5],
|
|
px: theme.spacing[9],
|
|
backgroundColor: theme.colors.backgroundPanel,
|
|
});
|
|
|
|
type Relation = "viewers" | "editors" | "builders" | "administrators";
|
|
|
|
export type LinkOptions = {
|
|
token: string;
|
|
name: string;
|
|
relation: Relation;
|
|
canCopy: boolean;
|
|
canClone: boolean;
|
|
};
|
|
|
|
type SharedLinkItemType = {
|
|
value: LinkOptions;
|
|
onChange: (value: LinkOptions) => void;
|
|
onDelete: () => void;
|
|
builderUrl: (props: {
|
|
authToken: string;
|
|
mode: "preview" | "edit";
|
|
}) => string;
|
|
hasProPlan: boolean;
|
|
};
|
|
|
|
const SharedLinkItem = ({
|
|
value,
|
|
onChange,
|
|
onDelete,
|
|
builderUrl,
|
|
hasProPlan,
|
|
}: SharedLinkItemType) => {
|
|
const [currentName, setCurrentName] = useState(value.name);
|
|
const [isCopied, setIsCopied] = useState(false);
|
|
|
|
return (
|
|
<Box className={itemStyle()}>
|
|
<Label css={{ flexGrow: 1 }}>{currentName}</Label>
|
|
<Tooltip
|
|
content={isCopied ? "Copied" : "Copy link"}
|
|
open={isCopied === true ? true : undefined}
|
|
onOpenChange={(isOpen) => {
|
|
if (isOpen === false) {
|
|
setIsCopied(false);
|
|
}
|
|
}}
|
|
>
|
|
<IconButton
|
|
aria-label="Copy link"
|
|
onClick={() => {
|
|
navigator.clipboard.writeText(
|
|
builderUrl({
|
|
authToken: value.token,
|
|
mode: value.relation === "viewers" ? "preview" : "edit",
|
|
})
|
|
);
|
|
setIsCopied(true);
|
|
}}
|
|
>
|
|
<CopyIcon aria-hidden />
|
|
</IconButton>
|
|
</Tooltip>
|
|
<Menu
|
|
name={currentName}
|
|
value={value}
|
|
onChange={(value) => {
|
|
setCurrentName(value.name);
|
|
onChange(value);
|
|
}}
|
|
onDelete={onDelete}
|
|
hasProPlan={hasProPlan}
|
|
/>
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
type ShareProjectProps = {
|
|
links?: Array<LinkOptions>;
|
|
onChange: (link: LinkOptions) => void;
|
|
onDelete: (link: LinkOptions) => void;
|
|
onCreate: () => void;
|
|
builderUrl: SharedLinkItemType["builderUrl"];
|
|
isPending: boolean;
|
|
hasProPlan: boolean;
|
|
};
|
|
|
|
const animateCollapsibleHeight = keyframes({
|
|
"0%": {
|
|
height: 0,
|
|
overflow: "hidden",
|
|
opacity: 0,
|
|
},
|
|
"100%": {
|
|
height: "var(--radix-collapsible-content-height)",
|
|
overflow: "hidden",
|
|
opacity: 1,
|
|
},
|
|
});
|
|
|
|
const collapsibleStyle = css({
|
|
animation: `${animateCollapsibleHeight} 200ms ${theme.easing.easeOut}`,
|
|
});
|
|
|
|
export const ShareProject = ({
|
|
links = [],
|
|
onChange,
|
|
onDelete,
|
|
onCreate,
|
|
builderUrl,
|
|
isPending,
|
|
hasProPlan,
|
|
}: ShareProjectProps) => {
|
|
const items = links.map((link) => (
|
|
<Fragment key={link.token}>
|
|
<SharedLinkItem
|
|
onChange={(value) => {
|
|
onChange(value);
|
|
}}
|
|
onDelete={() => {
|
|
onDelete(link);
|
|
}}
|
|
builderUrl={builderUrl}
|
|
value={link}
|
|
hasProPlan={hasProPlan}
|
|
/>
|
|
<Separator />
|
|
</Fragment>
|
|
));
|
|
|
|
const create = (
|
|
<Box className={itemStyle({ css: { py: theme.spacing["9"] } })}>
|
|
<Button
|
|
color="neutral"
|
|
state={isPending ? "pending" : undefined}
|
|
prefix={
|
|
isPending ? <Flex css={{ width: theme.spacing[9] }} /> : <PlusIcon />
|
|
}
|
|
onClick={() => {
|
|
onCreate();
|
|
}}
|
|
>
|
|
{links.length === 0 ? "Share a custom link" : "Add another link"}
|
|
</Button>
|
|
</Box>
|
|
);
|
|
|
|
return (
|
|
<Flex direction="column" css={{ width: theme.spacing[33] }}>
|
|
<Collapsible.Root open={items.length > 0}>
|
|
<Collapsible.Content className={collapsibleStyle()}>
|
|
{items}
|
|
</Collapsible.Content>
|
|
</Collapsible.Root>
|
|
|
|
{create}
|
|
</Flex>
|
|
);
|
|
};
|