mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: select group avatars with the emoji picker (#11395)
This commit is contained in:
Vendored
+5
-3
@@ -28,7 +28,7 @@ declare module "@emoji-mart/react" {
|
||||
| { unified: undefined; src: string }
|
||||
| { unified: string; src: undefined };
|
||||
|
||||
const EmojiPicker: React.FC<{
|
||||
export interface EmojiMartProps {
|
||||
set: "native" | "apple" | "facebook" | "google" | "twitter";
|
||||
theme: "dark" | "light";
|
||||
data: unknown;
|
||||
@@ -36,7 +36,9 @@ declare module "@emoji-mart/react" {
|
||||
emojiButtonSize?: number;
|
||||
emojiSize?: number;
|
||||
onEmojiSelect: (emoji: EmojiData) => void;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default EmojiPicker;
|
||||
const EmojiMart: React.FC<EmojiMartProps>;
|
||||
|
||||
export default EmojiMart;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Component, ReactNode, PropsWithChildren } from "react";
|
||||
import { Component, type ReactNode } from "react";
|
||||
import { RuntimeErrorState } from "./RuntimeErrorState";
|
||||
|
||||
type ErrorBoundaryProps = PropsWithChildren<unknown>;
|
||||
interface ErrorBoundaryProps {
|
||||
fallback?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
error: Error | null;
|
||||
@@ -9,7 +12,7 @@ interface ErrorBoundaryState {
|
||||
|
||||
/**
|
||||
* Our app's Error Boundary
|
||||
* Read more about React Error Boundaries: https://reactjs.org/docs/error-boundaries.html
|
||||
* Read more about React Error Boundaries: https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary
|
||||
*/
|
||||
export class ErrorBoundary extends Component<
|
||||
ErrorBoundaryProps,
|
||||
@@ -20,13 +23,15 @@ export class ErrorBoundary extends Component<
|
||||
this.state = { error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): { error: Error } {
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { error };
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.error) {
|
||||
return <RuntimeErrorState error={this.state.error} />;
|
||||
return (
|
||||
this.props.fallback ?? <RuntimeErrorState error={this.state.error} />
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import EmojiMart, { type EmojiMartProps } from "@emoji-mart/react";
|
||||
import data from "@emoji-mart/data/sets/14/twitter.json";
|
||||
import { type FC } from "react";
|
||||
import icons from "theme/icons.json";
|
||||
|
||||
const custom = [
|
||||
{
|
||||
id: "icons",
|
||||
name: "Icons",
|
||||
emojis: icons.map((icon) => {
|
||||
const id = icon.split(".")[0];
|
||||
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
keywords: id.split("-"),
|
||||
skins: [{ src: `/icon/${icon}` }],
|
||||
};
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
type EmojiPickerProps = Omit<
|
||||
EmojiMartProps,
|
||||
"custom" | "data" | "set" | "theme"
|
||||
>;
|
||||
|
||||
const EmojiPicker: FC<EmojiPickerProps> = (props) => {
|
||||
return (
|
||||
<EmojiMart
|
||||
theme="dark"
|
||||
set="twitter"
|
||||
data={data}
|
||||
custom={custom}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmojiPicker;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { action } from "@storybook/addon-actions";
|
||||
import IconField from "./IconField";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { IconField } from "./IconField";
|
||||
|
||||
const meta: Meta<typeof IconField> = {
|
||||
title: "components/IconField",
|
||||
|
||||
@@ -2,12 +2,11 @@ import { css, Global, useTheme } from "@emotion/react";
|
||||
import Button from "@mui/material/Button";
|
||||
import InputAdornment from "@mui/material/InputAdornment";
|
||||
import TextField, { type TextFieldProps } from "@mui/material/TextField";
|
||||
import Picker from "@emoji-mart/react";
|
||||
import { type FC } from "react";
|
||||
import { visuallyHidden } from "@mui/utils";
|
||||
import { type FC, lazy, Suspense } from "react";
|
||||
import { Loader } from "components/Loader/Loader";
|
||||
import { DropdownArrow } from "components/DropdownArrow/DropdownArrow";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import data from "@emoji-mart/data/sets/14/twitter.json";
|
||||
import icons from "theme/icons.json";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -22,24 +21,12 @@ type IconFieldProps = TextFieldProps & {
|
||||
onPickEmoji: (value: string) => void;
|
||||
};
|
||||
|
||||
const custom = [
|
||||
{
|
||||
id: "icons",
|
||||
name: "Icons",
|
||||
emojis: icons.map((icon) => {
|
||||
const id = icon.split(".")[0];
|
||||
const EmojiPicker = lazy(() => import("./EmojiPicker"));
|
||||
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
keywords: id.split("-"),
|
||||
skins: [{ src: `/icon/${icon}` }],
|
||||
};
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
const IconField: FC<IconFieldProps> = ({ onPickEmoji, ...textFieldProps }) => {
|
||||
export const IconField: FC<IconFieldProps> = ({
|
||||
onPickEmoji,
|
||||
...textFieldProps
|
||||
}) => {
|
||||
if (
|
||||
typeof textFieldProps.value !== "string" &&
|
||||
typeof textFieldProps.value !== "undefined"
|
||||
@@ -53,9 +40,9 @@ const IconField: FC<IconFieldProps> = ({ onPickEmoji, ...textFieldProps }) => {
|
||||
return (
|
||||
<Stack spacing={1}>
|
||||
<TextField
|
||||
{...textFieldProps}
|
||||
fullWidth
|
||||
label="Icon"
|
||||
{...textFieldProps}
|
||||
InputProps={{
|
||||
endAdornment: hasIcon ? (
|
||||
<InputAdornment
|
||||
@@ -86,6 +73,18 @@ const IconField: FC<IconFieldProps> = ({ onPickEmoji, ...textFieldProps }) => {
|
||||
}}
|
||||
/>
|
||||
|
||||
<Global
|
||||
styles={css`
|
||||
em-emoji-picker {
|
||||
--rgb-background: ${theme.palette.background.paper};
|
||||
--rgb-input: ${theme.palette.primary.main};
|
||||
--rgb-color: ${theme.palette.text.primary};
|
||||
|
||||
// Hack to prevent the right side from being cut off
|
||||
width: 350px;
|
||||
}
|
||||
`}
|
||||
/>
|
||||
<Popover>
|
||||
{(popover) => (
|
||||
<>
|
||||
@@ -98,35 +97,36 @@ const IconField: FC<IconFieldProps> = ({ onPickEmoji, ...textFieldProps }) => {
|
||||
id="emoji"
|
||||
css={{ marginTop: 0, ".MuiPaper-root": { width: "auto" } }}
|
||||
>
|
||||
<Global
|
||||
styles={css`
|
||||
em-emoji-picker {
|
||||
--rgb-background: ${theme.palette.background.paper};
|
||||
--rgb-input: ${theme.palette.primary.main};
|
||||
--rgb-color: ${theme.palette.text.primary};
|
||||
|
||||
// Hack to prevent the right side from being cut off
|
||||
width: 350px;
|
||||
}
|
||||
`}
|
||||
/>
|
||||
<Picker
|
||||
set="twitter"
|
||||
theme="dark"
|
||||
data={data}
|
||||
custom={custom}
|
||||
onEmojiSelect={(emoji) => {
|
||||
const value = emoji.src ?? urlFromUnifiedCode(emoji.unified);
|
||||
onPickEmoji(value);
|
||||
popover.setIsOpen(false);
|
||||
}}
|
||||
/>
|
||||
<Suspense fallback={<Loader />}>
|
||||
<EmojiPicker
|
||||
onEmojiSelect={(emoji) => {
|
||||
const value =
|
||||
emoji.src ?? urlFromUnifiedCode(emoji.unified);
|
||||
onPickEmoji(value);
|
||||
popover.setIsOpen(false);
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
</PopoverContent>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
|
||||
{/*
|
||||
- This component takes a long time to load (easily several seconds), so we
|
||||
don't want to wait until the user actually clicks the button to start loading.
|
||||
Unfortunately, React doesn't provide an API to start warming a lazy component,
|
||||
so we just have to sneak it into the DOM, which is kind of annoying, but means
|
||||
that users shouldn't ever spend time waiting for it to load.
|
||||
- Except we don't do it when running tests, because Jest doesn't define
|
||||
`IntersectionObserver`, and it would make them slower anyway. */}
|
||||
{process.env.NODE_ENV !== "test" && (
|
||||
<div css={{ ...visuallyHidden }}>
|
||||
<Suspense>
|
||||
<EmojiPicker onEmojiSelect={() => {}} />
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default IconField;
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { lazy, Suspense, type ComponentProps } from "react";
|
||||
|
||||
const IconField = lazy(() => import("./IconField"));
|
||||
|
||||
export const LazyIconField = (props: ComponentProps<typeof IconField>) => {
|
||||
return (
|
||||
<Suspense fallback={<div role="progressbar" data-testid="loader" />}>
|
||||
<IconField {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
HelpTooltipText,
|
||||
HelpTooltipTrigger,
|
||||
} from "components/HelpTooltip/HelpTooltip";
|
||||
import { LazyIconField } from "components/IconField/LazyIconField";
|
||||
import { IconField } from "components/IconField/IconField";
|
||||
import Link from "@mui/material/Link";
|
||||
import {
|
||||
HorizontalForm,
|
||||
@@ -345,12 +345,11 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = (props) => {
|
||||
label="Description"
|
||||
/>
|
||||
|
||||
<LazyIconField
|
||||
<IconField
|
||||
{...getFieldHelpers("icon")}
|
||||
disabled={isSubmitting}
|
||||
onChange={onChangeTrimmed(form)}
|
||||
fullWidth
|
||||
label="Icon"
|
||||
onPickEmoji={(value) => form.setFieldValue("icon", value)}
|
||||
/>
|
||||
</FormFields>
|
||||
|
||||
@@ -2,6 +2,7 @@ import TextField from "@mui/material/TextField";
|
||||
import { CreateGroupRequest } from "api/typesGenerated";
|
||||
import { FormFooter } from "components/FormFooter/FormFooter";
|
||||
import { FullPageForm } from "components/FullPageForm/FullPageForm";
|
||||
import { IconField } from "components/IconField/IconField";
|
||||
import { Margins } from "components/Margins/Margins";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import { useFormik } from "formik";
|
||||
@@ -58,12 +59,12 @@ export const CreateGroupPageView: FC<CreateGroupPageViewProps> = ({
|
||||
fullWidth
|
||||
label="Display Name"
|
||||
/>
|
||||
<TextField
|
||||
<IconField
|
||||
{...getFieldHelpers("avatar_url")}
|
||||
onChange={onChangeTrimmed(form)}
|
||||
autoComplete="avatar url"
|
||||
fullWidth
|
||||
label="Avatar URL"
|
||||
onPickEmoji={(value) => form.setFieldValue("avatar_url", value)}
|
||||
/>
|
||||
</Stack>
|
||||
<FormFooter onCancel={onCancel} isLoading={isLoading} />
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Group } from "api/typesGenerated";
|
||||
import { FormFooter } from "components/FormFooter/FormFooter";
|
||||
import { FullPageForm } from "components/FullPageForm/FullPageForm";
|
||||
import { Loader } from "components/Loader/Loader";
|
||||
import { LazyIconField } from "components/IconField/LazyIconField";
|
||||
import { IconField } from "components/IconField/IconField";
|
||||
import { Margins } from "components/Margins/Margins";
|
||||
import { useFormik } from "formik";
|
||||
import { FC } from "react";
|
||||
@@ -84,7 +84,7 @@ const UpdateGroupForm: FC<UpdateGroupFormProps> = ({
|
||||
label="Display Name"
|
||||
disabled={isEveryoneGroup(group)}
|
||||
/>
|
||||
<LazyIconField
|
||||
<IconField
|
||||
{...getFieldHelpers("avatar_url")}
|
||||
onChange={onChangeTrimmed(form)}
|
||||
fullWidth
|
||||
|
||||
+2
-2
@@ -11,7 +11,7 @@ import {
|
||||
iconValidator,
|
||||
} from "utils/formUtils";
|
||||
import * as Yup from "yup";
|
||||
import { LazyIconField } from "components/IconField/LazyIconField";
|
||||
import { IconField } from "components/IconField/IconField";
|
||||
import {
|
||||
FormFields,
|
||||
FormSection,
|
||||
@@ -126,7 +126,7 @@ export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
|
||||
rows={2}
|
||||
/>
|
||||
|
||||
<LazyIconField
|
||||
<IconField
|
||||
{...getFieldHelpers("icon")}
|
||||
disabled={isSubmitting}
|
||||
onChange={onChangeTrimmed(form)}
|
||||
|
||||
Reference in New Issue
Block a user