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
This commit is contained in:
Tom Moor
2024-02-03 11:23:25 -08:00
committed by GitHub
parent abaa56c8f1
commit 0a54227d97
38 changed files with 705 additions and 744 deletions

View File

@@ -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<HTMLFormElement>) => {
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<HTMLInputElement>) => {
setName(ev.target.value);
};
const handleChange = (color: string, icon: string) => {
setColor(color);
setIcon(icon);
};
return (
<Flex column>
<form onSubmit={handleSubmit}>
<Text as="p" type="secondary">
<Trans>
You can edit the name and other details at any time, however doing
so often might confuse your team mates.
</Trans>
</Text>
<Flex gap={8}>
<Input
type="text"
label={t("Name")}
onChange={handleNameChange}
maxLength={CollectionValidation.maxNameLength}
value={name}
required
autoFocus
flex
/>
<IconPicker
onChange={handleChange}
color={color}
initial={name[0]}
icon={icon}
/>
</Flex>
<InputSelect
label={t("Sort in sidebar")}
options={[
{
label: t("Alphabetical sort"),
value: "title.asc",
},
{
label: t("Manual sort"),
value: "index.asc",
},
]}
value={`${sort.field}.${sort.direction}`}
onChange={handleSortChange}
ariaLabel={t("Sort")}
/>
<Button type="submit" disabled={isSaving || !collection.name}>
{isSaving ? `${t("Saving")}` : t("Save")}
</Button>
</form>
</Flex>
);
};
export default observer(CollectionEdit);

View File

@@ -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<Props> {
@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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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 (
<form onSubmit={this.handleSubmit}>
<Text as="p" type="secondary">
<Trans>
Collections are for grouping your documents. They work best when
organized around a topic or internal team Product or Engineering
for example.
</Trans>
</Text>
<Flex gap={8}>
<Input
type="text"
label={t("Name")}
onChange={this.handleNameChange}
maxLength={CollectionValidation.maxNameLength}
value={this.name}
required
autoFocus
flex
/>
<IconPicker
onOpen={this.handleIconPickerOpen}
onChange={this.handleChange}
initial={this.name[0]}
color={this.color}
icon={this.icon}
/>
</Flex>
<InputSelectPermission
value={this.permission}
onChange={this.handlePermissionChange}
note={t(
"This is the default level of access, you can give individual users or groups more access once the collection is created."
)}
/>
{teamSharingEnabled && (
<Switch
id="sharing"
label={t("Public document sharing")}
onChange={this.handleSharingChange}
checked={this.sharing}
note={t(
"When enabled any documents within this collection can be shared publicly on the internet."
)}
/>
)}
<Button type="submit" disabled={this.isSaving || !this.name}>
{this.isSaving ? `${t("Creating")}` : t("Create")}
</Button>
</form>
);
}
}
export default withTranslation()(withStores(CollectionNew));

View File

@@ -208,7 +208,6 @@ class DocumentScene extends React.Component<Props> {
if (abilities.move) {
dialogs.openModal({
title: t("Move document"),
isCentered: true,
content: <DocumentMove document={document} />,
});
}
@@ -258,7 +257,6 @@ class DocumentScene extends React.Component<Props> {
} else {
dialogs.openModal({
title: t("Publish document"),
isCentered: true,
content: <DocumentPublish document={document} />,
});
}

View File

@@ -71,7 +71,6 @@ function ApiKeys() {
title={t("Create a token")}
onRequestClose={handleNewModalClose}
isOpen={newModalOpen}
isCentered
>
<APITokenNew onSubmit={handleNewModalClose} />
</Modal>

View File

@@ -113,7 +113,6 @@ function Details() {
dialogs.openModal({
title: t("Delete workspace"),
content: <TeamDelete onSubmit={dialogs.closeAllModals} />,
isCentered: true,
});
};

View File

@@ -24,7 +24,6 @@ function Export() {
dialogs.openModal({
title: t("Export data"),
isCentered: true,
content: <ExportDialog onSubmit={dialogs.closeAllModals} />,
});
},

View File

@@ -51,7 +51,6 @@ function Import() {
onClick={() => {
dialogs.openModal({
title: t("Import data"),
isCentered: true,
content: <ImportMarkdownDialog />,
});
}}
@@ -77,7 +76,6 @@ function Import() {
onClick={() => {
dialogs.openModal({
title: t("Import data"),
isCentered: true,
content: <ImportJSONDialog />,
});
}}
@@ -98,7 +96,6 @@ function Import() {
onClick={() => {
dialogs.openModal({
title: t("Import data"),
isCentered: true,
content: <ImportNotionDialog />,
});
}}

View File

@@ -43,7 +43,6 @@ function Preferences() {
dialogs.openModal({
title: t("Delete account"),
content: <UserDelete onSubmit={dialogs.closeAllModals} />,
isCentered: true,
});
};

View File

@@ -102,7 +102,6 @@ function Security() {
if (inviteRequired) {
dialogs.openModal({
isCentered: true,
title: t("Are you sure you want to require invites?"),
content: (
<ConfirmationDialog

View File

@@ -76,7 +76,6 @@ const FileOperationListItem = ({ fileOperation }: Props) => {
const handleConfirmDelete = React.useCallback(async () => {
dialogs.openModal({
isCentered: true,
title: t("Are you sure you want to delete this import?"),
content: (
<ConfirmationDialog