feat(editor): add basis of role editor pane

This commit is contained in:
41666 2020-10-25 02:27:55 -04:00
parent c8adad6c81
commit 5fcac53be2
34 changed files with 524 additions and 8 deletions

View file

@ -26,6 +26,7 @@
"@improbable-eng/grpc-web": "^0.13.0", "@improbable-eng/grpc-web": "^0.13.0",
"browser-headers": "^0.4.1", "browser-headers": "^0.4.1",
"chroma-js": "^2.1.0", "chroma-js": "^2.1.0",
"fuse.js": "^6.4.2",
"google-protobuf": "^3.13.0", "google-protobuf": "^3.13.0",
"next": "^9.5.5", "next": "^9.5.5",
"react": "^17.0.1", "react": "^17.0.1",
@ -90,4 +91,4 @@
"tsconfig-paths-webpack-plugin": "^3.3.0", "tsconfig-paths-webpack-plugin": "^3.3.0",
"typescript": "^4.0.3" "typescript": "^4.0.3"
} }
} }

View file

@ -0,0 +1 @@
package featureflags

View file

@ -0,0 +1,7 @@
load("//hack/bazel/js:react.bzl", "react_library")
package(default_visibility = ["//visibility:public"])
react_library(
name = "react",
)

View file

@ -0,0 +1,19 @@
import * as React from 'react';
export enum FeatureFlag {
AllowListsBlockLists = 'AllowListsBlockLists',
}
export class FeatureFlagProvider {
activeFlags: FeatureFlag[] = [];
constructor(flags: FeatureFlag[] = []) {
this.activeFlags = flags;
}
has(flag: FeatureFlag) {
return this.activeFlags.includes(flag);
}
}
export const FeatureFlagsContext = React.createContext(new FeatureFlagProvider());

View file

@ -0,0 +1 @@
export * from './FeatureFlags';

View file

@ -0,0 +1,12 @@
import * as React from 'react';
import { FeatureFlag, FeatureFlagProvider, FeatureFlagsContext } from './FeatureFlags';
export const FeatureFlagDecorator = (flags: FeatureFlag[]) => (
storyFn: () => React.ReactNode
) => {
return (
<FeatureFlagsContext.Provider value={new FeatureFlagProvider(flags)}>
{storyFn()}
</FeatureFlagsContext.Provider>
);
};

View file

@ -4,6 +4,4 @@ package(default_visibility = ["//visibility:public"])
react_library( react_library(
name = "withContext", name = "withContext",
deps = [
],
) )

View file

@ -0,0 +1,10 @@
load("//hack/bazel/js:react.bzl", "react_library")
package(default_visibility = ["//visibility:public"])
react_library(
name = "feature-gate",
deps = [
"//src/common/utils/featureFlags/react",
],
)

View file

@ -0,0 +1,16 @@
import * as React from 'react';
import { FeatureFlagDecorator } from 'roleypoly/src/common/utils/featureFlags/react/storyDecorator';
import { FeatureGate } from './FeatureGate';
export default {
title: 'Atoms/Feature Gate',
decorators: [FeatureFlagDecorator(['AllowListBlockList'])],
};
export const ActiveGate = () => (
<FeatureGate featureFlag="AllowListBlockList">{() => <div>hello!</div>}</FeatureGate>
);
export const InactiveGate = () => (
<FeatureGate featureFlag="aaa">{() => <div>hello!</div>}</FeatureGate>
);

View file

@ -0,0 +1,20 @@
import * as React from 'react';
import {
FeatureFlag,
FeatureFlagsContext,
} from 'roleypoly/src/common/utils/featureFlags/react';
export type FeatureGateProps = {
featureFlag: FeatureFlag;
children: () => React.ReactNode;
};
export const FeatureGate = (props: FeatureGateProps) => {
const featureContext = React.useContext(FeatureFlagsContext);
if (featureContext.has(props.featureFlag)) {
return props.children();
} else {
return <></>;
}
};

View file

@ -0,0 +1 @@
export * from './FeatureGate';

View file

@ -0,0 +1,11 @@
load("//hack/bazel/js:react.bzl", "react_library")
package(default_visibility = ["//visibility:public"])
react_library(
name = "horizontal-switch",
deps = [
"//src/design-system/atoms/colors",
"//src/design-system/atoms/timings",
],
)

View file

@ -0,0 +1,33 @@
import * as React from 'react';
import { HorizontalSwitch } from './HorizontalSwitch';
export default {
title: 'Atoms/Horizontal Switch',
component: HorizontalSwitch,
args: {
items: ['true', 'false'],
value: 'true',
},
};
const Story = (args) => {
const [value, setValue] = React.useState(args.value);
return (
<HorizontalSwitch
{...args}
value={value}
onChange={(a) => {
setValue(a);
args.onChange(a);
}}
/>
);
};
export const Switch = Story.bind({});
export const SwitchThree = Story.bind({});
SwitchThree.args = {
items: ['aaa', 'bbb', 'ccc'],
value: 'aaa',
};

View file

@ -0,0 +1,25 @@
import styled, { css } from 'styled-components';
import * as _ from 'styled-components'; // eslint-disable-line no-duplicate-imports
import { palette } from 'roleypoly/src/design-system/atoms/colors';
import { transitions } from 'roleypoly/src/design-system/atoms/timings';
export const Item = styled.div<{ selected: boolean }>`
padding: 10px;
box-sizing: border-box;
transition: background-color ease-in-out ${transitions.actionable}s;
${(props) =>
props.selected &&
css`
background-color: ${palette.taupe300};
`}
`;
export const Wrapper = styled.div`
display: inline-flex;
user-select: none;
cursor: pointer;
border: 1px solid ${palette.taupe200};
border-radius: calc(1em + 20px);
overflow: hidden;
`;

View file

@ -0,0 +1,28 @@
import * as React from 'react';
import { Item, Wrapper } from './HorizontalSwitch.styled';
export type SwitchProps = {
items: string[];
value: string;
onChange: (value: string) => void;
};
export const HorizontalSwitch = (props: SwitchProps) => {
const handleClick = (item: typeof props.value) => () => {
props.onChange?.(item);
};
return (
<Wrapper>
{props.items.map((item, idx) => (
<Item
key={idx}
selected={item === props.value}
onClick={handleClick(item)}
>
{item}
</Item>
))}
</Wrapper>
);
};

View file

@ -0,0 +1 @@
export * from './HorizontalSwitch';

View file

@ -10,7 +10,7 @@ import { globalOnKeyUp } from 'roleypoly/src/design-system/atoms/key-events';
import { IoMdClose } from 'react-icons/io'; import { IoMdClose } from 'react-icons/io';
type PopoverProps = { type PopoverProps = {
children: React.ReactNode; children: () => React.ReactNode;
position: 'top left' | 'top right' | 'bottom left' | 'bottom right'; position: 'top left' | 'top right' | 'bottom left' | 'bottom right';
active: boolean; active: boolean;
canDefocus?: boolean; canDefocus?: boolean;
@ -29,7 +29,7 @@ export const Popover = (props: PopoverProps) => {
</PopoverHeadCloser> </PopoverHeadCloser>
<div>{props.headContent}</div> <div>{props.headContent}</div>
</PopoverHead> </PopoverHead>
<PopoverContent>{props.children}</PopoverContent> <PopoverContent>{props.children()}</PopoverContent>
</PopoverBase> </PopoverBase>
{props.canDefocus && ( {props.canDefocus && (
<DefocusHandler <DefocusHandler

View file

@ -7,6 +7,7 @@ export type StyledProps = {
selected: boolean; selected: boolean;
defaultColor: boolean; defaultColor: boolean;
disabled: boolean; disabled: boolean;
type?: 'delete';
}; };
export const Outer = styled.div<StyledProps>` export const Outer = styled.div<StyledProps>`
@ -29,6 +30,9 @@ export const Outer = styled.div<StyledProps>`
&:hover { &:hover {
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
${Circle} svg {
fill-opacity: 1;
}
} }
&:active { &:active {
@ -69,6 +73,13 @@ export const Circle = styled.div<StyledProps>`
? 'var(--role-color)' ? 'var(--role-color)'
: 'var(--role-contrast)'}; : 'var(--role-contrast)'};
} }
${(props) =>
props.type === 'delete' &&
css`
svg {
fill-opacity: 0;
}
`}
`; `;
export const Text = styled.div` export const Text = styled.div`

View file

@ -11,6 +11,7 @@ type Props = {
disabled?: boolean; disabled?: boolean;
onClick?: (newState: boolean) => void; onClick?: (newState: boolean) => void;
tooltipId?: string; tooltipId?: string;
type?: 'delete';
}; };
export const Role = (props: Props) => { export const Role = (props: Props) => {
@ -36,6 +37,7 @@ export const Role = (props: Props) => {
selected: props.selected, selected: props.selected,
defaultColor: props.role.color === 0, defaultColor: props.role.color === 0,
disabled: !!props.disabled, disabled: !!props.disabled,
type: props.type,
}; };
const extra = !props.disabled const extra = !props.disabled
@ -53,7 +55,7 @@ export const Role = (props: Props) => {
{...extra} {...extra}
> >
<styled.Circle {...styledProps}> <styled.Circle {...styledProps}>
{!props.disabled ? <FaCheck /> : <FaTimes />} {!props.disabled && props.type !== 'delete' ? <FaCheck /> : <FaTimes />}
</styled.Circle> </styled.Circle>
<styled.Text>{props.role.name}</styled.Text> <styled.Text>{props.role.name}</styled.Text>
</styled.Outer> </styled.Outer>

View file

@ -0,0 +1,24 @@
import * as React from 'react';
import { EditorCategory } from './EditorCategory';
import {
mockCategory,
roleCategory2,
roleCategory,
guildRoles,
} from 'roleypoly/src/design-system/shared-types/storyData';
export default {
title: 'Molecules/Editor/Category',
};
export const CategoryEditor = () => {
const [categoryData, setCategoryData] = React.useState(mockCategory);
return (
<EditorCategory
category={categoryData}
onChange={(category) => setCategoryData(category)}
uncategorizedRoles={roleCategory}
guildRoles={[...roleCategory, ...roleCategory2]}
/>
);
};

View file

@ -0,0 +1,7 @@
import * as _ from 'styled-components'; // eslint-disable-line no-duplicate-imports
import styled from 'styled-components';
export const RoleContainer = styled.div`
display: flex;
margin: 10px;
`;

View file

@ -0,0 +1,150 @@
import * as React from 'react';
import { HorizontalSwitch } from 'roleypoly/src/design-system/atoms/horizontal-switch';
import { Space } from 'roleypoly/src/design-system/atoms/space';
import {
TextInput,
TextInputWithIcon,
} from 'roleypoly/src/design-system/atoms/text-input';
import { Text } from 'roleypoly/src/design-system/atoms/typography';
import { Popover } from 'roleypoly/src/design-system/atoms/popover';
import { FaderOpacity } from 'roleypoly/src/design-system/atoms/fader';
import { RoleSearch } from 'roleypoly/src/design-system/molecules/role-search';
import {
Category,
CategoryType,
Role as RoleType,
} from 'roleypoly/src/design-system/shared-types';
import { Role } from 'roleypoly/src/design-system/atoms/role';
import { GoSearch } from 'react-icons/go';
import { RoleContainer } from './EditorCategory.styled';
type Props = {
category: Category;
uncategorizedRoles: RoleType[];
guildRoles: RoleType[];
onChange: (category: Category) => void;
};
const typeEnumToSwitch = (typeData: CategoryType) => {
if (typeData === CategoryType.SINGLE) {
return 'Single';
} else {
return 'Multiple';
}
};
const switchToTypeEnum = (typeData: 'Single' | 'Multiple') => {
if (typeData === 'Single') {
return CategoryType.SINGLE;
} else {
return CategoryType.MULTI;
}
};
export const EditorCategory = (props: Props) => {
const [roleSearchPopoverActive, setRoleSearchPopoverActive] = React.useState(false);
const [roleSearchTerm, updateSearchTerm] = React.useState('');
const onUpdate = (
key: keyof typeof props.category,
pred?: (newValue: any) => any
) => (newValue: any) => {
props.onChange({
...props.category,
[key]: pred ? pred(newValue) : newValue,
});
};
const handleRoleSelect = (role: RoleType) => {
setRoleSearchPopoverActive(false);
updateSearchTerm('');
props.onChange({
...props.category,
rolesList: [...props.category.rolesList, role.id],
});
};
const handleRoleDeselect = (role: RoleType) => () => {
props.onChange({
...props.category,
rolesList: props.category.rolesList.filter((x) => x !== role.id),
});
};
return (
<div>
<Text>Category Name</Text>
<TextInput
placeholder="Pronouns, Political, Colors..."
value={props.category.name}
onChange={onUpdate('name', (x) => x.target.value)}
/>
<Space />
<Text>Selection Type</Text>
<div>
<HorizontalSwitch
items={['Multiple', 'Single']}
value={typeEnumToSwitch(props.category.type)}
onChange={onUpdate('type', switchToTypeEnum)}
/>
</div>
<Space />
<Text>Visiblity</Text>
<div>
<HorizontalSwitch
items={['Visible', 'Hidden']}
value={props.category.hidden ? 'Hidden' : 'Visible'}
onChange={onUpdate('hidden', (a) => a === 'Hidden')}
/>
</div>
<Space />
<Text>Roles</Text>
<Popover
active={roleSearchPopoverActive}
onExit={() => setRoleSearchPopoverActive(false)}
>
{() => (
<RoleSearch
placeholder={'Type or drag a role...'}
roles={props.uncategorizedRoles}
onSelect={handleRoleSelect}
searchTerm={roleSearchTerm}
onSearchUpdate={(newTerm) => updateSearchTerm(newTerm)}
/>
)}
</Popover>
<FaderOpacity isVisible={!roleSearchPopoverActive}>
<TextInputWithIcon
icon={<GoSearch />}
placeholder={'Type or drag a role...'}
onFocus={() => setRoleSearchPopoverActive(true)}
value={roleSearchTerm}
onChange={(x) => updateSearchTerm(x.target.value)}
/>
<RoleContainer>
{props.category.rolesList.map((id) => {
const role = props.guildRoles.find((x) => x.id === id);
if (!role) {
return <></>;
}
return (
<Role
role={role}
selected={false}
key={id}
type="delete"
onClick={handleRoleDeselect(role)}
/>
);
})}
</RoleContainer>
</FaderOpacity>
</div>
);
};

View file

@ -0,0 +1 @@
export * from './EditorCategory';

View file

@ -0,0 +1,10 @@
load("//hack/bazel/js:react.bzl", "react_library")
package(default_visibility = ["//visibility:public"])
react_library(
name = "help-page-base",
deps = [
"//src/design-system/atoms/colors",
],
)

View file

@ -0,0 +1,14 @@
import * as React from 'react';
import { RoleSearch } from './RoleSearch';
import { roleCategory } from 'roleypoly/src/design-system/shared-types/storyData';
export default {
title: 'Molecules/Role Search',
component: RoleSearch,
args: {
roles: roleCategory,
searchTerm: '',
},
};
export const Search = (args) => <RoleSearch {...args} />;

View file

@ -0,0 +1,57 @@
import styled from 'styled-components';
import Fuse from 'fuse.js';
import * as React from 'react';
import { GoSearch } from 'react-icons/go';
import { Role } from 'roleypoly/src/design-system/atoms/role';
import { Space } from 'roleypoly/src/design-system/atoms/space';
import { TextInputWithIcon } from 'roleypoly/src/design-system/atoms/text-input';
import { Role as RoleType } from 'roleypoly/src/design-system/shared-types';
type Props = {
roles: RoleType[];
placeholder?: string;
onSelect: (role: RoleType) => void;
onSearchUpdate: (newTerm: string) => void;
searchTerm: string;
};
export const RoleSearch = (props: Props) => {
const fuse = new Fuse(props.roles, { includeScore: true, keys: ['name'] });
const results =
props.searchTerm !== ''
? fuse.search(props.searchTerm)
: props.roles.map((role) => ({
item: role,
}));
const handleClick = (role: RoleType) => () => {
props.onSelect(role);
};
return (
<div>
<TextInputWithIcon
icon={<GoSearch />}
placeholder={props.placeholder || 'Search or drag a role...'}
value={props.searchTerm}
onChange={(x) => props.onSearchUpdate(x.target.value)}
/>
<Space />
{results.map((resultRole, idx) => (
<RoleInliner key={idx}>
<Role
selected={false}
role={resultRole.item}
onClick={handleClick(resultRole.item)}
key={`${idx}role`}
/>
</RoleInliner>
))}
</div>
);
};
const RoleInliner = styled.div`
display: flex;
margin: 5px 0;
`;

View file

@ -0,0 +1 @@
export * from './RoleSearch';

View file

@ -0,0 +1,10 @@
import * as React from 'react';
import { EditorShell } from './EditorShell';
import { guildEnum } from 'roleypoly/src/design-system/shared-types/storyData';
export default {
title: 'Organisms/Editor',
component: EditorShell,
};
export const Shell = () => <EditorShell guild={guildEnum.guildsList[0]} />;

View file

@ -0,0 +1,8 @@
import styled from 'styled-components';
import { palette } from 'roleypoly/src/design-system/atoms/colors';
export const CategoryContainer = styled.div`
background-color: ${palette.taupe100};
padding: 10px;
margin: 15px 0;
`;

View file

@ -0,0 +1,31 @@
import * as React from 'react';
import { TabView, Tab } from 'roleypoly/src/design-system/atoms/tab-view';
import { PresentableGuild } from 'roleypoly/src/design-system/shared-types';
import { EditorCategory } from '../../molecules/editor-category';
import { CategoryContainer } from './EditorShell.styled';
type Props = {
guild: PresentableGuild;
};
export const EditorShell = (props: Props) => (
<TabView selected={0}>
<Tab title="Roles">{() => <RolesTab {...props} />}</Tab>
<Tab title="Server Details">{() => <div>hi2!</div>}</Tab>
</TabView>
);
const RolesTab = (props: Props) => (
<div>
{props.guild.data.categoriesList.map((category, idx) => (
<CategoryContainer key={idx}>
<EditorCategory
category={category}
uncategorizedRoles={[]}
guildRoles={props.guild.roles.rolesList}
onChange={(x) => console.log(x)}
/>
</CategoryContainer>
))}
</div>
);

View file

@ -0,0 +1 @@
export * from './EditorShell';

View file

@ -3,7 +3,7 @@ load("//hack/bazel/js:react.bzl", "react_library")
package(default_visibility = ["//visibility:public"]) package(default_visibility = ["//visibility:public"])
react_library( react_library(
name = "role-picker", name = "preauth",
deps = [ deps = [
"react-icons", "react-icons",
"//src/design-system/atoms/button", "//src/design-system/atoms/button",

View file

@ -19,7 +19,7 @@
"declaration": true, "declaration": true,
"moduleResolution": "node", "moduleResolution": "node",
"paths": { "paths": {
"roleypoly/*": ["./*", "./bazel-bin/*"] "roleypoly/*": ["./*", "./dist/bin/*"]
} }
} }
} }

View file

@ -7082,6 +7082,11 @@ fuse.js@^3.6.1:
resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-3.6.1.tgz#7de85fdd6e1b3377c23ce010892656385fd9b10c" resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-3.6.1.tgz#7de85fdd6e1b3377c23ce010892656385fd9b10c"
integrity sha512-hT9yh/tiinkmirKrlv4KWOjztdoZo1mx9Qh4KvWqC7isoXwdUY3PNWUxceF4/qO9R6riA2C29jdTOeQOIROjgw== integrity sha512-hT9yh/tiinkmirKrlv4KWOjztdoZo1mx9Qh4KvWqC7isoXwdUY3PNWUxceF4/qO9R6riA2C29jdTOeQOIROjgw==
fuse.js@^6.4.2:
version "6.4.2"
resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-6.4.2.tgz#bd1400164de6562c077baeaa54ed642b4c2fca62"
integrity sha512-jyy+zOtV96ylMqOGpVjAWQfvEkfTtuJRjsOC6pjReeju8SoDZ2vgFF6eur0a8fxsVwwcpdt3ieaQQeLdkqXH1Q==
gauge@~2.7.3: gauge@~2.7.3:
version "2.7.4" version "2.7.4"
resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7" resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7"