From 0a54227d97e5e2d3579c0086c800a5c7e48a9dc3 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sat, 3 Feb 2024 11:23:25 -0800 Subject: [PATCH] Refactor collection creation UI (#6485) * Iteration, before functional component * Use react-hook-form, shared form for new and edit * Avoid negative margin on input prefix * Centered now default for modals --- app/actions/definitions/collections.tsx | 6 +- app/actions/definitions/documents.tsx | 7 - app/actions/definitions/teams.tsx | 1 + app/actions/definitions/users.tsx | 2 +- app/components/Collection/CollectionEdit.tsx | 32 ++ app/components/Collection/CollectionForm.tsx | 164 ++++++++ app/components/Collection/CollectionNew.tsx | 32 ++ app/components/Dialogs.tsx | 2 +- app/components/IconPicker.tsx | 391 +++--------------- app/components/Icons/CollectionIcon.tsx | 4 +- app/components/Icons/IconLibrary.tsx | 300 ++++++++++++++ app/components/Input.tsx | 18 +- app/components/InputSelect.tsx | 20 +- app/components/InputSelectPermission.tsx | 18 +- app/components/Modal.tsx | 54 +-- app/components/Popover.tsx | 8 +- app/components/Sharing/PublicAccess.tsx | 3 +- .../Sidebar/components/TrashLink.tsx | 1 - app/components/Switch.tsx | 26 +- app/menus/ApiKeyMenu.tsx | 1 - app/menus/CollectionMenu.tsx | 1 - app/menus/CommentMenu.tsx | 1 - app/menus/GroupMenu.tsx | 1 - app/menus/UserMenu.tsx | 5 - app/scenes/CollectionEdit.tsx | 129 ------ app/scenes/CollectionNew.tsx | 175 -------- app/scenes/Document/components/Document.tsx | 2 - app/scenes/Settings/ApiKeys.tsx | 1 - app/scenes/Settings/Details.tsx | 1 - app/scenes/Settings/Export.tsx | 1 - app/scenes/Settings/Import.tsx | 3 - app/scenes/Settings/Preferences.tsx | 1 - app/scenes/Settings/Security.tsx | 1 - .../components/FileOperationListItem.tsx | 1 - app/stores/DialogsStore.ts | 8 +- .../WebhookSubscriptionListItem.tsx | 1 - shared/i18n/locales/en_US/translation.json | 25 +- shared/styles/theme.ts | 2 +- 38 files changed, 705 insertions(+), 744 deletions(-) create mode 100644 app/components/Collection/CollectionEdit.tsx create mode 100644 app/components/Collection/CollectionForm.tsx create mode 100644 app/components/Collection/CollectionNew.tsx create mode 100644 app/components/Icons/IconLibrary.tsx delete mode 100644 app/scenes/CollectionEdit.tsx delete mode 100644 app/scenes/CollectionNew.tsx diff --git a/app/actions/definitions/collections.tsx b/app/actions/definitions/collections.tsx index 693ecbdc91..700c84a2c9 100644 --- a/app/actions/definitions/collections.tsx +++ b/app/actions/definitions/collections.tsx @@ -10,9 +10,9 @@ import { import * as React from "react"; import stores from "~/stores"; import Collection from "~/models/Collection"; -import CollectionEdit from "~/scenes/CollectionEdit"; -import CollectionNew from "~/scenes/CollectionNew"; import CollectionPermissions from "~/scenes/CollectionPermissions"; +import { CollectionEdit } from "~/components/Collection/CollectionEdit"; +import { CollectionNew } from "~/components/Collection/CollectionNew"; import CollectionDeleteDialog from "~/components/CollectionDeleteDialog"; import DynamicCollectionIcon from "~/components/Icons/CollectionIcon"; import { createAction } from "~/actions"; @@ -103,6 +103,7 @@ export const editCollectionPermissions = createAction({ stores.dialogs.openModal({ title: t("Collection permissions"), + fullscreen: true, content: , }); }, @@ -183,7 +184,6 @@ export const deleteCollection = createAction({ } stores.dialogs.openModal({ - isCentered: true, title: t("Delete collection"), content: ( , }); } @@ -345,7 +344,6 @@ export const shareDocument = createAction({ stores.dialogs.openModal({ title: t("Share this document"), - isCentered: true, content: ( , }); }, @@ -751,7 +747,6 @@ export const moveDocument = createAction({ title: t("Move {{ documentType }}", { documentType: document.noun, }), - isCentered: true, content: , }); } @@ -805,7 +800,6 @@ export const deleteDocument = createAction({ title: t("Delete {{ documentName }}", { documentName: document.noun, }), - isCentered: true, content: ( , }); }, diff --git a/app/actions/definitions/users.tsx b/app/actions/definitions/users.tsx index c2ba446118..df6082d6b4 100644 --- a/app/actions/definitions/users.tsx +++ b/app/actions/definitions/users.tsx @@ -17,6 +17,7 @@ export const inviteUser = createAction({ perform: ({ t }) => { stores.dialogs.openModal({ title: t("Invite people"), + fullscreen: true, content: , }); }, @@ -38,7 +39,6 @@ export const deleteUserActionFactory = (userId: string) => stores.dialogs.openModal({ title: t("Delete user"), - isCentered: true, content: ( void; +}; + +export const CollectionEdit = observer(function CollectionEdit_({ + collectionId, + onSubmit, +}: Props) { + const { collections } = useStores(); + const collection = collections.get(collectionId); + + const handleSubmit = React.useCallback( + async (data: FormData) => { + try { + await collection?.save(data); + onSubmit?.(); + } catch (error) { + toast.error(error.message); + } + }, + [collection, onSubmit] + ); + + return ; +}); diff --git a/app/components/Collection/CollectionForm.tsx b/app/components/Collection/CollectionForm.tsx new file mode 100644 index 0000000000..aa0a8e4c2e --- /dev/null +++ b/app/components/Collection/CollectionForm.tsx @@ -0,0 +1,164 @@ +import { observer } from "mobx-react"; +import * as React from "react"; +import { Controller, useForm } from "react-hook-form"; +import { Trans, useTranslation } from "react-i18next"; +import styled from "styled-components"; +import { randomElement } from "@shared/random"; +import { CollectionPermission } from "@shared/types"; +import { colorPalette } from "@shared/utils/collections"; +import { CollectionValidation } from "@shared/validations"; +import Collection from "~/models/Collection"; +import Button from "~/components/Button"; +import Flex from "~/components/Flex"; +import IconPicker from "~/components/IconPicker"; +import { IconLibrary } from "~/components/Icons/IconLibrary"; +import Input from "~/components/Input"; +import InputSelectPermission from "~/components/InputSelectPermission"; +import Switch from "~/components/Switch"; +import Text from "~/components/Text"; +import useBoolean from "~/hooks/useBoolean"; +import useCurrentTeam from "~/hooks/useCurrentTeam"; + +export interface FormData { + name: string; + icon: string; + color: string; + sharing: boolean; + permission: CollectionPermission | undefined; +} + +export const CollectionForm = observer(function CollectionForm_({ + handleSubmit, + collection, +}: { + handleSubmit: (data: FormData) => void; + collection?: Collection; +}) { + const team = useCurrentTeam(); + const { t } = useTranslation(); + const [hasOpenedIconPicker, setHasOpenedIconPicker] = useBoolean(false); + const { + register, + handleSubmit: formHandleSubmit, + formState, + watch, + control, + setValue, + setFocus, + } = useForm({ + mode: "all", + defaultValues: { + name: collection?.name ?? "", + icon: collection?.icon, + sharing: collection?.sharing ?? true, + permission: collection?.permission, + color: collection?.color ?? randomElement(colorPalette), + }, + }); + + const values = watch(); + + React.useEffect(() => { + // If the user hasn't picked an icon yet, go ahead and suggest one based on + // the name of the collection. It's the little things sometimes. + if (!hasOpenedIconPicker) { + setValue( + "icon", + IconLibrary.findIconByKeyword(values.name) ?? + values.icon ?? + "collection" + ); + } + }, [values.name]); + + const handleIconPickerChange = React.useCallback( + (color: string, icon: string) => { + if (icon !== values.icon) { + setFocus("name"); + } + + setValue("color", color); + setValue("icon", icon); + }, + [setFocus, setValue, values.icon] + ); + + return ( +
+ + + Collections are used to group documents and choose permissions + + . + + + + } + autoFocus + flex + /> + + ( + { + field.onChange(value); + }} + note={t( + "The default access for workspace members, you can share with more users or groups later." + )} + /> + )} + /> + + {team.sharing && ( + + )} + + + + + + ); +}); + +const StyledIconPicker = styled(IconPicker)` + margin-left: 4px; + margin-right: 4px; +`; diff --git a/app/components/Collection/CollectionNew.tsx b/app/components/Collection/CollectionNew.tsx new file mode 100644 index 0000000000..c00586120a --- /dev/null +++ b/app/components/Collection/CollectionNew.tsx @@ -0,0 +1,32 @@ +import { observer } from "mobx-react"; +import * as React from "react"; +import { toast } from "sonner"; +import Collection from "~/models/Collection"; +import useStores from "~/hooks/useStores"; +import history from "~/utils/history"; +import { CollectionForm, FormData } from "./CollectionForm"; + +type Props = { + onSubmit: () => void; +}; + +export const CollectionNew = observer(function CollectionNew_({ + onSubmit, +}: Props) { + const { collections } = useStores(); + const handleSubmit = React.useCallback( + async (data: FormData) => { + try { + const collection = new Collection(data, collections); + await collection.save(); + onSubmit?.(); + history.push(collection.path); + } catch (error) { + toast.error(error.message); + } + }, + [collections, onSubmit] + ); + + return ; +}); diff --git a/app/components/Dialogs.tsx b/app/components/Dialogs.tsx index afd6b611b5..12d56294e4 100644 --- a/app/components/Dialogs.tsx +++ b/app/components/Dialogs.tsx @@ -22,7 +22,7 @@ function Dialogs() { dialogs.closeModal(id)} title={modal.title} > diff --git a/app/components/IconPicker.tsx b/app/components/IconPicker.tsx index 7a17eea75e..c642399696 100644 --- a/app/components/IconPicker.tsx +++ b/app/components/IconPicker.tsx @@ -1,282 +1,23 @@ -import { - BookmarkedIcon, - BicycleIcon, - AcademicCapIcon, - BeakerIcon, - BuildingBlocksIcon, - BrowserIcon, - CollectionIcon, - CoinsIcon, - CameraIcon, - CarrotIcon, - FlameIcon, - HashtagIcon, - GraphIcon, - InternetIcon, - LibraryIcon, - PlaneIcon, - RamenIcon, - CloudIcon, - CodeIcon, - EditIcon, - EmailIcon, - EyeIcon, - GlobeIcon, - InfoIcon, - ImageIcon, - LeafIcon, - LightBulbIcon, - MathIcon, - MoonIcon, - NotepadIcon, - PadlockIcon, - PaletteIcon, - PromoteIcon, - QuestionMarkIcon, - SportIcon, - SunIcon, - ShapesIcon, - TargetIcon, - TerminalIcon, - ToolsIcon, - VehicleIcon, - WarningIcon, - DatabaseIcon, - SmileyIcon, - LightningIcon, - ClockIcon, - DoneIcon, - FeedbackIcon, - ServerRackIcon, - ThumbsUpIcon, -} from "outline-icons"; import * as React from "react"; import { useTranslation } from "react-i18next"; -import { useMenuState, MenuButton, MenuItem } from "reakit/Menu"; +import { PopoverDisclosure, usePopoverState } from "reakit"; +import { MenuItem } from "reakit/Menu"; import styled, { useTheme } from "styled-components"; -import breakpoint from "styled-components-breakpoint"; -import { s } from "@shared/styles"; import { colorPalette } from "@shared/utils/collections"; -import ContextMenu from "~/components/ContextMenu"; import Flex from "~/components/Flex"; -import { LabelText } from "~/components/Input"; import NudeButton from "~/components/NudeButton"; import Text from "~/components/Text"; import lazyWithRetry from "~/utils/lazyWithRetry"; import DelayedMount from "./DelayedMount"; -import LetterIcon from "./Icons/LetterIcon"; +import { IconLibrary } from "./Icons/IconLibrary"; +import Popover from "./Popover"; + +const icons = IconLibrary.mapping; const TwitterPicker = lazyWithRetry( () => import("react-color/lib/components/twitter/Twitter") ); -export const icons = { - academicCap: { - component: AcademicCapIcon, - keywords: "learn teach lesson guide tutorial onboarding training", - }, - bicycle: { - component: BicycleIcon, - keywords: "bicycle bike cycle", - }, - beaker: { - component: BeakerIcon, - keywords: "lab research experiment test", - }, - buildingBlocks: { - component: BuildingBlocksIcon, - keywords: "app blocks product prototype", - }, - bookmark: { - component: BookmarkedIcon, - keywords: "bookmark", - }, - browser: { - component: BrowserIcon, - keywords: "browser web app", - }, - collection: { - component: CollectionIcon, - keywords: "collection", - }, - coins: { - component: CoinsIcon, - keywords: "coins money finance sales income revenue cash", - }, - camera: { - component: CameraIcon, - keywords: "photo picture", - }, - carrot: { - component: CarrotIcon, - keywords: "food vegetable produce", - }, - clock: { - component: ClockIcon, - keywords: "time", - }, - cloud: { - component: CloudIcon, - keywords: "cloud service aws infrastructure", - }, - code: { - component: CodeIcon, - keywords: "developer api code development engineering programming", - }, - database: { - component: DatabaseIcon, - keywords: "server ops database", - }, - done: { - component: DoneIcon, - keywords: "checkmark success complete finished", - }, - email: { - component: EmailIcon, - keywords: "email at", - }, - eye: { - component: EyeIcon, - keywords: "eye view", - }, - feedback: { - component: FeedbackIcon, - keywords: "faq help support", - }, - flame: { - component: FlameIcon, - keywords: "fire hot", - }, - graph: { - component: GraphIcon, - keywords: "chart analytics data", - }, - globe: { - component: GlobeIcon, - keywords: "world translate", - }, - hashtag: { - component: HashtagIcon, - keywords: "social media tag", - }, - info: { - component: InfoIcon, - keywords: "info information", - }, - image: { - component: ImageIcon, - keywords: "image photo picture", - }, - internet: { - component: InternetIcon, - keywords: "network global globe world", - }, - leaf: { - component: LeafIcon, - keywords: "leaf plant outdoors nature ecosystem climate", - }, - library: { - component: LibraryIcon, - keywords: "library collection archive", - }, - lightbulb: { - component: LightBulbIcon, - keywords: "lightbulb idea", - }, - lightning: { - component: LightningIcon, - keywords: "lightning fast zap", - }, - letter: { - component: LetterIcon, - keywords: "letter", - }, - math: { - component: MathIcon, - keywords: "math formula", - }, - moon: { - component: MoonIcon, - keywords: "night moon dark", - }, - notepad: { - component: NotepadIcon, - keywords: "journal notepad write notes", - }, - padlock: { - component: PadlockIcon, - keywords: "padlock private security authentication authorization auth", - }, - palette: { - component: PaletteIcon, - keywords: "design palette art brand", - }, - pencil: { - component: EditIcon, - keywords: "copy writing post blog", - }, - plane: { - component: PlaneIcon, - keywords: "airplane travel flight trip vacation", - }, - promote: { - component: PromoteIcon, - keywords: "marketing promotion", - }, - ramen: { - component: RamenIcon, - keywords: "soup food noodle bowl meal", - }, - question: { - component: QuestionMarkIcon, - keywords: "question help support faq", - }, - server: { - component: ServerRackIcon, - keywords: "ops infra server", - }, - sun: { - component: SunIcon, - keywords: "day sun weather", - }, - shapes: { - component: ShapesIcon, - keywords: "blocks toy", - }, - sport: { - component: SportIcon, - keywords: "sport outdoor racket game", - }, - smiley: { - component: SmileyIcon, - keywords: "emoji smiley happy", - }, - target: { - component: TargetIcon, - keywords: "target goal sales", - }, - terminal: { - component: TerminalIcon, - keywords: "terminal code", - }, - thumbsup: { - component: ThumbsUpIcon, - keywords: "like social favorite upvote", - }, - tools: { - component: ToolsIcon, - keywords: "tool settings", - }, - vehicle: { - component: VehicleIcon, - keywords: "truck car travel transport", - }, - warning: { - component: WarningIcon, - keywords: "warning alert error", - }, -}; - type Props = { onOpen?: () => void; onClose?: () => void; @@ -284,6 +25,7 @@ type Props = { initial: string; icon: string; color: string; + className?: string; }; function IconPicker({ @@ -293,46 +35,70 @@ function IconPicker({ initial, color, onChange, + className, }: Props) { const { t } = useTranslation(); const theme = useTheme(); - const menu = useMenuState({ - modal: true, + const popover = usePopoverState({ + gutter: 0, placement: "bottom-end", + modal: true, }); + React.useEffect(() => { + if (popover.visible) { + onOpen?.(); + } else { + onClose?.(); + } + }, [onOpen, onClose, popover.visible]); + + const styles = React.useMemo( + () => ({ + default: { + body: { + padding: 0, + marginRight: -8, + }, + hash: { + color: theme.text, + background: theme.inputBorder, + }, + swatch: { + cursor: "var(--cursor-pointer)", + }, + input: { + color: theme.text, + boxShadow: `inset 0 0 0 1px ${theme.inputBorder}`, + background: "transparent", + }, + }, + }), + [theme] + ); + return ( - - - + <> + {(props) => ( - + )} - - + + {Object.keys(icons).map((name, index) => ( - onChange(color, name)} - {...menu} - > + onChange(color, name)}> {(props) => ( - + {initial} @@ -363,28 +133,12 @@ function IconPicker({ onChange={(color) => onChange(color.hex, icon)} colors={colorPalette} triangle="hide" - styles={{ - default: { - body: { - padding: 0, - marginRight: -8, - }, - hash: { - color: theme.text, - background: theme.inputBorder, - }, - input: { - color: theme.text, - boxShadow: `inset 0 0 0 1px ${theme.inputBorder}`, - background: "transparent", - }, - }, - }} + styles={styles} /> - - + + ); } @@ -397,22 +151,8 @@ const Colors = styled(Flex)` padding: 8px; `; -const Label = styled.label` - display: block; -`; - const Icons = styled.div` padding: 8px; - - ${breakpoint("tablet")` - width: 304px; - `}; -`; - -const Button = styled(NudeButton)` - border: 1px solid ${s("inputBorder")}; - width: 32px; - height: 32px; `; const IconButton = styled(NudeButton)` @@ -429,9 +169,4 @@ const ColorPicker = styled(TwitterPicker)` width: 100% !important; `; -const Wrapper = styled("div")` - display: inline-block; - position: relative; -`; - export default IconPicker; diff --git a/app/components/Icons/CollectionIcon.tsx b/app/components/Icons/CollectionIcon.tsx index 52cddac24b..89582f4e25 100644 --- a/app/components/Icons/CollectionIcon.tsx +++ b/app/components/Icons/CollectionIcon.tsx @@ -3,9 +3,9 @@ import { CollectionIcon } from "outline-icons"; import { getLuminance } from "polished"; import * as React from "react"; import Collection from "~/models/Collection"; -import { icons } from "~/components/IconPicker"; import useStores from "~/hooks/useStores"; import Logger from "~/utils/Logger"; +import { IconLibrary } from "./IconLibrary"; type Props = { /** The collection to show an icon for */ @@ -38,7 +38,7 @@ function ResolvedCollectionIcon({ if (collection.icon && collection.icon !== "collection") { try { - const Component = icons[collection.icon].component; + const Component = IconLibrary.getComponent(collection.icon); return ( {collection.initial} diff --git a/app/components/Icons/IconLibrary.tsx b/app/components/Icons/IconLibrary.tsx new file mode 100644 index 0000000000..831c97a70f --- /dev/null +++ b/app/components/Icons/IconLibrary.tsx @@ -0,0 +1,300 @@ +import { intersection } from "lodash"; +import { + BookmarkedIcon, + BicycleIcon, + AcademicCapIcon, + BeakerIcon, + BuildingBlocksIcon, + BrowserIcon, + CollectionIcon, + CoinsIcon, + CameraIcon, + CarrotIcon, + FlameIcon, + HashtagIcon, + GraphIcon, + InternetIcon, + LibraryIcon, + PlaneIcon, + RamenIcon, + CloudIcon, + CodeIcon, + EditIcon, + EmailIcon, + EyeIcon, + GlobeIcon, + InfoIcon, + ImageIcon, + LeafIcon, + LightBulbIcon, + MathIcon, + MoonIcon, + NotepadIcon, + PadlockIcon, + PaletteIcon, + PromoteIcon, + QuestionMarkIcon, + SportIcon, + SunIcon, + ShapesIcon, + TargetIcon, + TerminalIcon, + ToolsIcon, + VehicleIcon, + WarningIcon, + DatabaseIcon, + SmileyIcon, + LightningIcon, + ClockIcon, + DoneIcon, + FeedbackIcon, + ServerRackIcon, + ThumbsUpIcon, +} from "outline-icons"; +import LetterIcon from "./LetterIcon"; + +export class IconLibrary { + /** + * Get the component for a given icon name + * + * @param icon The name of the icon + * @returns The component for the icon + */ + public static getComponent(icon: string) { + return this.mapping[icon].component; + } + + /** + * Find an icon by keyword. This is useful for searching for an icon based on a user's input. + * + * @param keyword The keyword to search for + * @returns The name of the icon that matches the keyword, or undefined if no match is found + */ + public static findIconByKeyword(keyword: string) { + const keys = Object.keys(this.mapping); + + for (const key of keys) { + const icon = this.mapping[key]; + const keywords = icon.keywords.split(" "); + const namewords = keyword.toLocaleLowerCase().split(" "); + const matches = intersection(namewords, keywords); + + if (matches.length > 0) { + return key; + } + } + + return undefined; + } + + /** + * A map of all icons available to end users in the app. This does not include icons that are used + * internally only, which can be imported from `outline-icons` directly. + */ + public static mapping = { + academicCap: { + component: AcademicCapIcon, + keywords: "learn teach lesson guide tutorial onboarding training", + }, + bicycle: { + component: BicycleIcon, + keywords: "bicycle bike cycle", + }, + beaker: { + component: BeakerIcon, + keywords: "lab research experiment test", + }, + buildingBlocks: { + component: BuildingBlocksIcon, + keywords: "app blocks product prototype", + }, + bookmark: { + component: BookmarkedIcon, + keywords: "bookmark", + }, + browser: { + component: BrowserIcon, + keywords: "browser web app", + }, + collection: { + component: CollectionIcon, + keywords: "collection", + }, + coins: { + component: CoinsIcon, + keywords: "coins money finance sales income revenue cash", + }, + camera: { + component: CameraIcon, + keywords: "photo picture", + }, + carrot: { + component: CarrotIcon, + keywords: "food vegetable produce", + }, + clock: { + component: ClockIcon, + keywords: "time", + }, + cloud: { + component: CloudIcon, + keywords: "cloud service aws infrastructure", + }, + code: { + component: CodeIcon, + keywords: "developer api code development engineering programming", + }, + database: { + component: DatabaseIcon, + keywords: "server ops database", + }, + done: { + component: DoneIcon, + keywords: "checkmark success complete finished", + }, + email: { + component: EmailIcon, + keywords: "email at", + }, + eye: { + component: EyeIcon, + keywords: "eye view", + }, + feedback: { + component: FeedbackIcon, + keywords: "faq help support", + }, + flame: { + component: FlameIcon, + keywords: "fire hot", + }, + graph: { + component: GraphIcon, + keywords: "chart analytics data", + }, + globe: { + component: GlobeIcon, + keywords: "world translate", + }, + hashtag: { + component: HashtagIcon, + keywords: "social media tag", + }, + info: { + component: InfoIcon, + keywords: "info information", + }, + image: { + component: ImageIcon, + keywords: "image photo picture", + }, + internet: { + component: InternetIcon, + keywords: "network global globe world", + }, + leaf: { + component: LeafIcon, + keywords: "leaf plant outdoors nature ecosystem climate", + }, + library: { + component: LibraryIcon, + keywords: "library collection archive", + }, + lightbulb: { + component: LightBulbIcon, + keywords: "lightbulb idea", + }, + lightning: { + component: LightningIcon, + keywords: "lightning fast zap", + }, + letter: { + component: LetterIcon, + keywords: "letter", + }, + math: { + component: MathIcon, + keywords: "math formula", + }, + moon: { + component: MoonIcon, + keywords: "night moon dark", + }, + notepad: { + component: NotepadIcon, + keywords: "journal notepad write notes", + }, + padlock: { + component: PadlockIcon, + keywords: "padlock private security authentication authorization auth", + }, + palette: { + component: PaletteIcon, + keywords: "design palette art brand", + }, + pencil: { + component: EditIcon, + keywords: "copy writing post blog", + }, + plane: { + component: PlaneIcon, + keywords: "airplane travel flight trip vacation", + }, + promote: { + component: PromoteIcon, + keywords: "marketing promotion", + }, + ramen: { + component: RamenIcon, + keywords: "soup food noodle bowl meal", + }, + question: { + component: QuestionMarkIcon, + keywords: "question help support faq", + }, + server: { + component: ServerRackIcon, + keywords: "ops infra server", + }, + sun: { + component: SunIcon, + keywords: "day sun weather", + }, + shapes: { + component: ShapesIcon, + keywords: "blocks toy", + }, + sport: { + component: SportIcon, + keywords: "sport outdoor racket game", + }, + smiley: { + component: SmileyIcon, + keywords: "emoji smiley happy", + }, + target: { + component: TargetIcon, + keywords: "target goal sales", + }, + terminal: { + component: TerminalIcon, + keywords: "terminal code", + }, + thumbsup: { + component: ThumbsUpIcon, + keywords: "like social favorite upvote", + }, + tools: { + component: ToolsIcon, + keywords: "tool settings", + }, + vehicle: { + component: VehicleIcon, + keywords: "truck car travel transport", + }, + warning: { + component: WarningIcon, + keywords: "warning alert error", + }, + }; +} diff --git a/app/components/Input.tsx b/app/components/Input.tsx index 5e13ab264b..3def4e5ac3 100644 --- a/app/components/Input.tsx +++ b/app/components/Input.tsx @@ -8,10 +8,14 @@ import Flex from "~/components/Flex"; import Text from "~/components/Text"; import { undraggableOnDesktop } from "~/styles"; -export const NativeTextarea = styled.textarea<{ hasIcon?: boolean }>` +export const NativeTextarea = styled.textarea<{ + hasIcon?: boolean; + hasPrefix?: boolean; +}>` border: 0; flex: 1; - padding: 8px 12px 8px ${(props) => (props.hasIcon ? "8px" : "12px")}; + padding: 8px 12px 8px + ${(props) => (props.hasPrefix ? 0 : props.hasIcon ? "8px" : "12px")}; outline: none; background: none; color: ${s("text")}; @@ -23,10 +27,14 @@ export const NativeTextarea = styled.textarea<{ hasIcon?: boolean }>` } `; -export const NativeInput = styled.input<{ hasIcon?: boolean }>` +export const NativeInput = styled.input<{ + hasIcon?: boolean; + hasPrefix?: boolean; +}>` border: 0; flex: 1; - padding: 8px 12px 8px ${(props) => (props.hasIcon ? "8px" : "12px")}; + padding: 8px 12px 8px + ${(props) => (props.hasPrefix ? 0 : props.hasIcon ? "8px" : "12px")}; outline: none; background: none; color: ${s("text")}; @@ -224,6 +232,7 @@ function Input( onFocus={handleFocus} onKeyDown={handleKeyDown} hasIcon={!!icon} + hasPrefix={!!prefix} {...rest} /> ) : ( @@ -236,6 +245,7 @@ function Input( onFocus={handleFocus} onKeyDown={handleKeyDown} hasIcon={!!icon} + hasPrefix={!!prefix} type={type} {...rest} /> diff --git a/app/components/InputSelect.tsx b/app/components/InputSelect.tsx index da2933876d..49456f71c2 100644 --- a/app/components/InputSelect.tsx +++ b/app/components/InputSelect.tsx @@ -48,6 +48,12 @@ export type Props = { onChange?: (value: string | null) => void; }; +export interface InputSelectRef { + value: string | null; + focus: () => void; + blur: () => void; +} + interface InnerProps extends React.HTMLAttributes { placement: Placement; } @@ -55,7 +61,7 @@ interface InnerProps extends React.HTMLAttributes { const getOptionFromValue = (options: Option[], value: string | null) => options.find((option) => option.value === value); -const InputSelect = (props: Props) => { +const InputSelect = (props: Props, ref: React.RefObject) => { const { value = null, label, @@ -122,6 +128,16 @@ const InputSelect = (props: Props) => { { capture: true } ); + React.useImperativeHandle(ref, () => ({ + focus: () => { + buttonRef.current?.focus(); + }, + blur: () => { + buttonRef.current?.blur(); + }, + value: select.selectedValue, + })); + React.useEffect(() => { previousValue.current = value; select.setSelectedValue(value); @@ -306,4 +322,4 @@ export const Positioner = styled(Position)` } `; -export default InputSelect; +export default React.forwardRef(InputSelect); diff --git a/app/components/InputSelectPermission.tsx b/app/components/InputSelectPermission.tsx index 1ecff92705..c1f69c4b62 100644 --- a/app/components/InputSelectPermission.tsx +++ b/app/components/InputSelectPermission.tsx @@ -3,33 +3,31 @@ import { useTranslation } from "react-i18next"; import { $Diff } from "utility-types"; import { CollectionPermission } from "@shared/types"; import { EmptySelectValue } from "~/types"; -import InputSelect, { Props, Option } from "./InputSelect"; +import InputSelect, { Props, Option, InputSelectRef } from "./InputSelect"; -export default function InputSelectPermission( +function InputSelectPermission( props: $Diff< Props, { options: Array diff --git a/app/menus/UserMenu.tsx b/app/menus/UserMenu.tsx index c4e10a6bcc..717c5c795a 100644 --- a/app/menus/UserMenu.tsx +++ b/app/menus/UserMenu.tsx @@ -40,7 +40,6 @@ function UserMenu({ user }: Props) { ev.preventDefault(); dialogs.openModal({ title: t("Change role to admin"), - isCentered: true, content: ( ), @@ -105,7 +101,6 @@ function UserMenu({ user }: Props) { ev.preventDefault(); dialogs.openModal({ title: t("Suspend user"), - isCentered: true, content: ( ), diff --git a/app/scenes/CollectionEdit.tsx b/app/scenes/CollectionEdit.tsx deleted file mode 100644 index 48a0efe626..0000000000 --- a/app/scenes/CollectionEdit.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import invariant from "invariant"; -import { observer } from "mobx-react"; -import { useState } from "react"; -import * as React from "react"; -import { Trans, useTranslation } from "react-i18next"; -import { toast } from "sonner"; -import { CollectionValidation } from "@shared/validations"; -import Button from "~/components/Button"; -import Flex from "~/components/Flex"; -import IconPicker from "~/components/IconPicker"; -import Input from "~/components/Input"; -import InputSelect from "~/components/InputSelect"; -import Text from "~/components/Text"; -import useStores from "~/hooks/useStores"; - -type Props = { - collectionId: string; - onSubmit: () => void; -}; - -const CollectionEdit = ({ collectionId, onSubmit }: Props) => { - const { collections } = useStores(); - const collection = collections.get(collectionId); - invariant(collection, "Collection not found"); - const [name, setName] = useState(collection.name); - const [icon, setIcon] = useState(collection.icon); - const [color, setColor] = useState(collection.color || "#4E5C6E"); - const [sort, setSort] = useState<{ - field: string; - direction: "asc" | "desc"; - }>(collection.sort); - const [isSaving, setIsSaving] = useState(false); - const { t } = useTranslation(); - - const handleSubmit = React.useCallback( - async (ev: React.SyntheticEvent) => { - ev.preventDefault(); - setIsSaving(true); - - try { - await collection.save({ - name, - icon, - color, - sort, - }); - onSubmit(); - toast.success(t("The collection was updated")); - } catch (err) { - toast.error(err.message); - } finally { - setIsSaving(false); - } - }, - [collection, color, icon, name, onSubmit, sort, t] - ); - - const handleSortChange = (value: string) => { - const [field, direction] = value.split("."); - - if (direction === "asc" || direction === "desc") { - setSort({ - field, - direction, - }); - } - }; - - const handleNameChange = (ev: React.ChangeEvent) => { - setName(ev.target.value); - }; - - const handleChange = (color: string, icon: string) => { - setColor(color); - setIcon(icon); - }; - - return ( - -
- - - You can edit the name and other details at any time, however doing - so often might confuse your team mates. - - - - - - - - - -
- ); -}; - -export default observer(CollectionEdit); diff --git a/app/scenes/CollectionNew.tsx b/app/scenes/CollectionNew.tsx deleted file mode 100644 index 55cb571c47..0000000000 --- a/app/scenes/CollectionNew.tsx +++ /dev/null @@ -1,175 +0,0 @@ -import intersection from "lodash/intersection"; -import { observable } from "mobx"; -import { observer } from "mobx-react"; -import * as React from "react"; -import { withTranslation, Trans, WithTranslation } from "react-i18next"; -import { toast } from "sonner"; -import { randomElement } from "@shared/random"; -import { CollectionPermission } from "@shared/types"; -import { colorPalette } from "@shared/utils/collections"; -import { CollectionValidation } from "@shared/validations"; -import RootStore from "~/stores/RootStore"; -import Collection from "~/models/Collection"; -import Button from "~/components/Button"; -import Flex from "~/components/Flex"; -import IconPicker, { icons } from "~/components/IconPicker"; -import Input from "~/components/Input"; -import InputSelectPermission from "~/components/InputSelectPermission"; -import Switch from "~/components/Switch"; -import Text from "~/components/Text"; -import withStores from "~/components/withStores"; -import history from "~/utils/history"; - -type Props = RootStore & - WithTranslation & { - onSubmit: () => void; - }; - -@observer -class CollectionNew extends React.Component { - @observable - name = ""; - - @observable - icon = ""; - - @observable - color = randomElement(colorPalette); - - @observable - sharing = true; - - @observable - permission = CollectionPermission.ReadWrite; - - @observable - isSaving: boolean; - - hasOpenedIconPicker = false; - - handleSubmit = async (ev: React.SyntheticEvent) => { - ev.preventDefault(); - this.isSaving = true; - const collection = new Collection( - { - name: this.name, - sharing: this.sharing, - icon: this.icon, - color: this.color, - permission: this.permission, - documents: [], - }, - this.props.collections - ); - - try { - await collection.save(); - this.props.onSubmit(); - history.push(collection.path); - } catch (err) { - toast.error(err.message); - } finally { - this.isSaving = false; - } - }; - - handleNameChange = (ev: React.ChangeEvent) => { - this.name = ev.target.value; - - // If the user hasn't picked an icon yet, go ahead and suggest one based on - // the name of the collection. It's the little things sometimes. - if (!this.hasOpenedIconPicker) { - const keys = Object.keys(icons); - - for (const key of keys) { - const icon = icons[key]; - const keywords = icon.keywords.split(" "); - const namewords = this.name.toLowerCase().split(" "); - const matches = intersection(namewords, keywords); - - if (matches.length > 0) { - this.icon = key; - return; - } - } - - this.icon = "collection"; - } - }; - - handleIconPickerOpen = () => { - this.hasOpenedIconPicker = true; - }; - - handlePermissionChange = (permission: CollectionPermission) => { - this.permission = permission; - }; - - handleSharingChange = (ev: React.ChangeEvent) => { - this.sharing = ev.target.checked; - }; - - handleChange = (color: string, icon: string) => { - this.color = color; - this.icon = icon; - }; - - render() { - const { t, auth } = this.props; - const teamSharingEnabled = !!auth.team && auth.team.sharing; - return ( -
- - - Collections are for grouping your documents. They work best when - organized around a topic or internal team — Product or Engineering - for example. - - - - - - - - {teamSharingEnabled && ( - - )} - - - - ); - } -} - -export default withTranslation()(withStores(CollectionNew)); diff --git a/app/scenes/Document/components/Document.tsx b/app/scenes/Document/components/Document.tsx index ce5bcbe371..a231a90ee0 100644 --- a/app/scenes/Document/components/Document.tsx +++ b/app/scenes/Document/components/Document.tsx @@ -208,7 +208,6 @@ class DocumentScene extends React.Component { if (abilities.move) { dialogs.openModal({ title: t("Move document"), - isCentered: true, content: , }); } @@ -258,7 +257,6 @@ class DocumentScene extends React.Component { } else { dialogs.openModal({ title: t("Publish document"), - isCentered: true, content: , }); } diff --git a/app/scenes/Settings/ApiKeys.tsx b/app/scenes/Settings/ApiKeys.tsx index 60e453d7d4..52e30694fe 100644 --- a/app/scenes/Settings/ApiKeys.tsx +++ b/app/scenes/Settings/ApiKeys.tsx @@ -71,7 +71,6 @@ function ApiKeys() { title={t("Create a token")} onRequestClose={handleNewModalClose} isOpen={newModalOpen} - isCentered > diff --git a/app/scenes/Settings/Details.tsx b/app/scenes/Settings/Details.tsx index 37a072c831..5b2ad2e46b 100644 --- a/app/scenes/Settings/Details.tsx +++ b/app/scenes/Settings/Details.tsx @@ -113,7 +113,6 @@ function Details() { dialogs.openModal({ title: t("Delete workspace"), content: , - isCentered: true, }); }; diff --git a/app/scenes/Settings/Export.tsx b/app/scenes/Settings/Export.tsx index e25d4a638a..112b2b12f7 100644 --- a/app/scenes/Settings/Export.tsx +++ b/app/scenes/Settings/Export.tsx @@ -24,7 +24,6 @@ function Export() { dialogs.openModal({ title: t("Export data"), - isCentered: true, content: , }); }, diff --git a/app/scenes/Settings/Import.tsx b/app/scenes/Settings/Import.tsx index 334df921d1..ae85e37279 100644 --- a/app/scenes/Settings/Import.tsx +++ b/app/scenes/Settings/Import.tsx @@ -51,7 +51,6 @@ function Import() { onClick={() => { dialogs.openModal({ title: t("Import data"), - isCentered: true, content: , }); }} @@ -77,7 +76,6 @@ function Import() { onClick={() => { dialogs.openModal({ title: t("Import data"), - isCentered: true, content: , }); }} @@ -98,7 +96,6 @@ function Import() { onClick={() => { dialogs.openModal({ title: t("Import data"), - isCentered: true, content: , }); }} diff --git a/app/scenes/Settings/Preferences.tsx b/app/scenes/Settings/Preferences.tsx index 6f62f2efe7..0755a6adb0 100644 --- a/app/scenes/Settings/Preferences.tsx +++ b/app/scenes/Settings/Preferences.tsx @@ -43,7 +43,6 @@ function Preferences() { dialogs.openModal({ title: t("Delete account"), content: , - isCentered: true, }); }; diff --git a/app/scenes/Settings/Security.tsx b/app/scenes/Settings/Security.tsx index f2a4f1a47d..d933c7847c 100644 --- a/app/scenes/Settings/Security.tsx +++ b/app/scenes/Settings/Security.tsx @@ -102,7 +102,6 @@ function Security() { if (inviteRequired) { dialogs.openModal({ - isCentered: true, title: t("Are you sure you want to require invites?"), content: ( { const handleConfirmDelete = React.useCallback(async () => { dialogs.openModal({ - isCentered: true, title: t("Are you sure you want to delete this import?"), content: ( { @@ -64,8 +64,8 @@ export default class DialogsStore { this.modalStack.set(id, { title, content, + fullscreen, isOpen: true, - isCentered, }); }), 0 diff --git a/plugins/webhooks/client/components/WebhookSubscriptionListItem.tsx b/plugins/webhooks/client/components/WebhookSubscriptionListItem.tsx index 2e785682f0..19a04690db 100644 --- a/plugins/webhooks/client/components/WebhookSubscriptionListItem.tsx +++ b/plugins/webhooks/client/components/WebhookSubscriptionListItem.tsx @@ -25,7 +25,6 @@ const WebhookSubscriptionListItem = ({ webhook }: Props) => { const showDeletionConfirmation = React.useCallback(() => { dialogs.openModal({ title: t("Delete webhook"), - isCentered: true, content: ( {{titleWithDefault}} 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.": "Creating a template from {{titleWithDefault}} 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.", "Currently editing": "Currently editing", "Currently viewing": "Currently viewing", @@ -214,16 +222,16 @@ "{{ count }} member": "{{ count }} member", "{{ count }} member_plural": "{{ count }} members", "Group members": "Group members", - "Icon": "Icon", "Show menu": "Show menu", "Choose icon": "Choose icon", "Loading": "Loading", "Select a color": "Select a color", "Search": "Search", - "Default access": "Default access", + "Permission": "Permission", "Can edit": "Can edit", "View only": "View only", "No access": "No access", + "Default access": "Default access", "Role": "Role", "Editor": "Editor", "Viewer": "Viewer", @@ -296,14 +304,12 @@ "No results": "No results", "Previous page": "Previous page", "Next page": "Next page", - "Saving": "Saving", "Are you sure you want to make {{ userName }} a read-only viewer? They will not be able to edit any content": "Are you sure you want to make {{ userName }} a read-only viewer? They will not be able to edit any content", "Are you sure you want to make {{ userName }} a member?": "Are you sure you want to make {{ userName }} a member?", "I understand, delete": "I understand, delete", "Are you sure you want to permanently delete {{ userName }}? This operation is unrecoverable, consider suspending the user instead.": "Are you sure you want to permanently delete {{ userName }}? This operation is unrecoverable, consider suspending the user instead.", "Are you sure you want to make {{ userName }} an admin? Admins can modify team and billing information.": "Are you sure you want to make {{ userName }} an admin? Admins can modify team and billing information.", "Are you sure you want to suspend {{ userName }}? Suspended users will be prevented from logging in.": "Are you sure you want to suspend {{ userName }}? Suspended users will be prevented from logging in.", - "Save": "Save", "New name": "New name", "Name can't be empty": "Name can't be empty", "Your import completed": "Your import completed", @@ -484,15 +490,6 @@ "{{ usersCount }} users with access_plural": "{{ usersCount }} users with access", "{{ groupsCount }} groups with access": "{{ groupsCount }} group with access", "{{ groupsCount }} groups with access_plural": "{{ groupsCount }} groups with access", - "The collection was updated": "The collection was updated", - "You can edit the name and other details at any time, however doing so often might confuse your team mates.": "You can edit the name and other details at any time, however doing so often might confuse your team mates.", - "Name": "Name", - "Sort": "Sort", - "Collections are for grouping your documents. They work best when organized around a topic or internal team — Product or Engineering for example.": "Collections are for grouping your documents. They work best when organized around a topic or internal team — Product or Engineering for example.", - "This is the default level of access, you can give individual users or groups more access once the collection is created.": "This is the default level of access, you can give individual users or groups more access once the collection is created.", - "Public document sharing": "Public document sharing", - "When enabled any documents within this collection can be shared publicly on the internet.": "When enabled any documents within this collection can be shared publicly on the internet.", - "Create": "Create", "{{ groupName }} was added to the collection": "{{ groupName }} was added to the collection", "Could not add user": "Could not add user", "Can’t find the group you’re looking for?": "Can’t find the group you’re looking for?", diff --git a/shared/styles/theme.ts b/shared/styles/theme.ts index 8514c20842..5f10aa263b 100644 --- a/shared/styles/theme.ts +++ b/shared/styles/theme.ts @@ -130,7 +130,7 @@ export const buildLightTheme = (input: Partial): DefaultTheme => { commentBackground: colors.warmGrey, - modalBackdrop: colors.black10, + modalBackdrop: "rgba(0, 0, 0, 0.15)", modalBackground: colors.white, modalShadow: "0 4px 8px rgb(0 0 0 / 8%), 0 2px 4px rgb(0 0 0 / 0%), 0 30px 40px rgb(0 0 0 / 8%)",