From 971d38290a684c7574b4083bccdf1952157739d9 Mon Sep 17 00:00:00 2001 From: Liam Fitzgerald Date: Fri, 18 Sep 2020 15:48:26 +1000 Subject: [PATCH] groups: update to new designs --- pkg/interface/package-lock.json | 5 +- pkg/interface/package.json | 2 +- .../src/logic/lib/useLocalStorageState.ts | 40 +++-- .../apps/groups/components/GroupSwitcher.tsx | 161 +++++++++++++----- .../views/apps/publish/PublishResource.tsx | 33 ++-- .../src/views/components/Dropdown.tsx | 11 +- .../src/views/components/FormikOnBlur.tsx | 33 ++++ .../src/views/components/GroupsPane.tsx | 109 +++++++----- .../src/views/components/Sidebar.tsx | 141 ++++++++------- .../src/views/components/SidebarItem.tsx | 22 ++- .../views/components/SidebarListHeader.tsx | 60 +++++++ .../src/views/components/Skeleton.tsx | 18 +- 12 files changed, 434 insertions(+), 201 deletions(-) create mode 100644 pkg/interface/src/views/components/FormikOnBlur.tsx create mode 100644 pkg/interface/src/views/components/SidebarListHeader.tsx diff --git a/pkg/interface/package-lock.json b/pkg/interface/package-lock.json index f379ea40f..b841ed836 100644 --- a/pkg/interface/package-lock.json +++ b/pkg/interface/package-lock.json @@ -1709,9 +1709,8 @@ "integrity": "sha512-3OPSdf9cejP/TSzWXuBaYbzLtAfBzQnc75SlPLkoPfwpxnv1Bvy9hiWngLY0WnKRR6lMOldnkYQCCuNWeDibYQ==" }, "@tlon/indigo-react": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@tlon/indigo-react/-/indigo-react-1.1.15.tgz", - "integrity": "sha512-Ao+1hAJjN5y1gDyT7GIUgXORPXTIpZKVVtrS++ZGYBemYMSq3oJFMIZertsSZbDHuh/TsVPenJrMUZBpV60law==" + "version": "github:liam-fitzgerald/indigo-react#e88a94b1556c029300fc08b33190060aac559f20", + "from": "github:liam-fitzgerald/indigo-react#lf/1.1.17" }, "@types/anymatch": { "version": "1.3.1", diff --git a/pkg/interface/package.json b/pkg/interface/package.json index 45e644e8b..ed8e26174 100644 --- a/pkg/interface/package.json +++ b/pkg/interface/package.json @@ -9,7 +9,7 @@ "@reach/menu-button": "^0.10.5", "@reach/tabs": "^0.10.5", "@tlon/indigo-light": "^1.0.3", - "@tlon/indigo-react": "^1.1.15", + "@tlon/indigo-react": "github:liam-fitzgerald/indigo-react#lf/1.1.17", "aws-sdk": "^2.726.0", "classnames": "^2.2.6", "codemirror": "^5.55.0", diff --git a/pkg/interface/src/logic/lib/useLocalStorageState.ts b/pkg/interface/src/logic/lib/useLocalStorageState.ts index a3f81c666..cf0b77683 100644 --- a/pkg/interface/src/logic/lib/useLocalStorageState.ts +++ b/pkg/interface/src/logic/lib/useLocalStorageState.ts @@ -1,22 +1,36 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useEffect } from "react"; -export function useLocalStorageState(key: string, initial: T) { - const [state, _setState] = useState(() => { - const s = localStorage.getItem(key); - if(s) { +function retrieve(key: string, initial: T): T { + const s = localStorage.getItem(key); + if (s) { + try { return JSON.parse(s) as T; + } catch (e) { + return initial; } - return initial; + } + return initial; +} - }); +interface SetStateFunc { + (t: T): T; +} +type SetState = T | SetStateFunc; +export function useLocalStorageState(key: string, initial: T) { + const [state, _setState] = useState(() => retrieve(key, initial)); - const setState = useCallback((s: T) => { - _setState(s); - localStorage.setItem(key, JSON.stringify(s)); + useEffect(() => { + _setState(retrieve(key, initial)); + }, [key]); - }, [_setState]); + const setState = useCallback( + (s: SetState) => { + const updated = typeof s === "function" ? s(state) : s; + _setState(updated); + localStorage.setItem(key, JSON.stringify(s)); + }, + [_setState] + ); return [state, setState] as const; } - - diff --git a/pkg/interface/src/views/apps/groups/components/GroupSwitcher.tsx b/pkg/interface/src/views/apps/groups/components/GroupSwitcher.tsx index 292e126a7..f3d7b43b1 100644 --- a/pkg/interface/src/views/apps/groups/components/GroupSwitcher.tsx +++ b/pkg/interface/src/views/apps/groups/components/GroupSwitcher.tsx @@ -1,76 +1,145 @@ -import React from 'react'; -import { Box, Col, Row, Text, IconButton, Button, Icon } from '@tlon/indigo-react'; -import { Link } from 'react-router-dom'; +import React from "react"; +import { + Center, + Box, + Col, + Row, + Text, + IconButton, + Button, + Icon, +} from "@tlon/indigo-react"; +import { uxToHex } from "~/logic/lib/util"; +import { Link } from "react-router-dom"; -import { Association, } from "~/types/metadata-update"; +import { Association, Associations } from "~/types/metadata-update"; import { Dropdown } from "~/views/components/Dropdown"; - -const GroupSwitcherItem = ({ to, children, ...rest }) => ( +const GroupSwitcherItem = ({ to, children, bottom = false, ...rest }) => ( - - {children} - + + + {children} + + ); -export function GroupSwitcher(props: { association: Association; baseUrl: string }) { +function RecentGroups(props: { recent: string[]; associations: Associations }) { + const { associations, recent } = props; + if (recent.length < 2) { + return null; + } + + return ( + + + Recent Groups + + {props.recent.slice(1, 5).map((g) => { + const assoc = associations.contacts[g]; + const color = uxToHex(assoc?.metadata?.color); + return ( + + + {assoc?.metadata?.title} + + ); + })} + + ); +} + +export function GroupSwitcher(props: { + association: Association; + associations: Associations; + baseUrl: string; + recentGroups: string[]; +}) { const { title } = props.association.metadata; const navTo = (to: string) => `${props.baseUrl}${to}`; return ( - - + + - - + + - Switch Groups - + All Groups + + - + Participants - + Settings - - - Group Settings - - - + + Invite to group } > - + + - - - + + + + + + + + diff --git a/pkg/interface/src/views/apps/publish/PublishResource.tsx b/pkg/interface/src/views/apps/publish/PublishResource.tsx index e1eab7956..283c58c8d 100644 --- a/pkg/interface/src/views/apps/publish/PublishResource.tsx +++ b/pkg/interface/src/views/apps/publish/PublishResource.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { Box } from '@tlon/indigo-react'; import GlobalApi from "~/logic/api/global"; import { StoreState } from "~/logic/store/type"; @@ -20,19 +21,23 @@ export function PublishResource(props: PublishResourceProps) { const notebookContacts = props.contacts[association["group-path"]]; return ( - + + + ); } diff --git a/pkg/interface/src/views/components/Dropdown.tsx b/pkg/interface/src/views/components/Dropdown.tsx index db2eb0ce4..ac736d1bb 100644 --- a/pkg/interface/src/views/components/Dropdown.tsx +++ b/pkg/interface/src/views/components/Dropdown.tsx @@ -15,9 +15,10 @@ const ClickBox = styled(Box)` cursor: pointer; `; -const DropdownOptions = styled(Box)` +const DropdownOptions = styled(Box)<{ pos: string }>` z-index: 20; position: absolute; + ${(p) => p.pos}: -1px; `; export function Dropdown(props: DropdownProps) { @@ -35,20 +36,18 @@ export function Dropdown(props: DropdownProps) { const [open, setOpen] = useState(false); - const position = { [props.position]: "0px" }; - const align = props.position === "right" ? "flex-end" : "flex-start"; return ( - + setOpen((o) => !o)}> {children} {open && ( - + diff --git a/pkg/interface/src/views/components/FormikOnBlur.tsx b/pkg/interface/src/views/components/FormikOnBlur.tsx new file mode 100644 index 000000000..1cce30273 --- /dev/null +++ b/pkg/interface/src/views/components/FormikOnBlur.tsx @@ -0,0 +1,33 @@ +import React, { useImperativeHandle, useEffect } from "react"; +import { FormikValues, useFormik, FormikProvider, FormikConfig } from "formik"; + +export function FormikOnBlur< + Values extends FormikValues = FormikValues, + ExtraProps = {} +>(props: FormikConfig & ExtraProps) { + const formikBag = useFormik({ ...props, validateOnBlur: true }); + + useEffect(() => { + if ( + Object.keys(formikBag.errors || {}).length === 0 && + Object.keys(formikBag.touched || {}).length !== 0 && + !formikBag.isSubmitting + ) { + const { values } = formikBag; + formikBag.submitForm().then(() => { + formikBag.resetForm({ values }); + }); + } + }, [ + formikBag.errors, + formikBag.touched, + formikBag.submitForm, + formikBag.values, + ]); + + const { children, innerRef } = props; + + useImperativeHandle(innerRef, () => formikBag); + + return {children}; +} diff --git a/pkg/interface/src/views/components/GroupsPane.tsx b/pkg/interface/src/views/components/GroupsPane.tsx index b55928bd4..9db4e665e 100644 --- a/pkg/interface/src/views/components/GroupsPane.tsx +++ b/pkg/interface/src/views/components/GroupsPane.tsx @@ -1,6 +1,12 @@ -import React from "react"; -import { Switch, Route, useLocation } from "react-router-dom"; +import React, { useState, useEffect } from "react"; +import { + Switch, + Route, + useLocation, + RouteComponentProps, +} from "react-router-dom"; import { Center } from "@tlon/indigo-react"; +import _ from "lodash"; import { Resource } from "~/views/components/Resource"; import { PopoverRoutes } from "~/views/apps/groups/components/PopoverRoutes"; @@ -15,6 +21,7 @@ import GlobalApi from "~/logic/api/global"; import { StoreState } from "~/logic/store/type"; import { UnjoinedResource } from "./UnjoinedResource"; import { InvitePopover } from "../apps/groups/components/InvitePopover"; +import { useLocalStorageState } from "~/logic/lib/useLocalStorageState"; type GroupsPaneProps = StoreState & { baseUrl: string; @@ -29,15 +36,48 @@ export function GroupsPane(props: GroupsPaneProps) { const groupContacts = contacts[groupPath]; const groupAssociation = associations.contacts[groupPath]; const group = groups[groupPath]; - const location = useLocation(); - const mobileHide = location.pathname === baseUrl; + + const [recentGroups, setRecentGroups] = useLocalStorageState( + "recent-groups", + [] + ); + + useEffect(() => { + if (!groupPath) { + return; + } + setRecentGroups((gs) => _.uniq([groupPath, ...gs])); + }, [groupPath]); + + if(!groupAssociation) { + return null; + } + + const popovers = (routeProps: RouteComponentProps, baseUrl: string) => ( + <> + + + + ); return ( { const { app, host, name } = routeProps.match.params as Record< string, @@ -46,7 +86,7 @@ export function GroupsPane(props: GroupsPaneProps) { const resource = `/${host}/${name}`; const appName = app as AppName; const association = associations[appName][resource]; - const resourceUrl = `${baseUrl}/resource/${app}${resource}` + const resourceUrl = `${baseUrl}/resource/${app}${resource}`; if (!association) { return null; @@ -55,6 +95,7 @@ export function GroupsPane(props: GroupsPaneProps) { return ( - - + {popovers(routeProps, resourceUrl)} ); }} @@ -94,17 +120,18 @@ export function GroupsPane(props: GroupsPaneProps) { const appName = app as AppName; const appPath = `/${host}/${name}`; const association = associations[appName][appPath]; + const resourceUrl = `${baseUrl}/join/${app}/${host}/${name}`; return ( - + - + {popovers(routeProps, resourceUrl)} ); }} @@ -113,18 +140,16 @@ export function GroupsPane(props: GroupsPaneProps) { path={relativePath("")} render={(routeProps) => { return ( - +
Open something to get started
- + {popovers(routeProps, baseUrl)}
); }} diff --git a/pkg/interface/src/views/components/Sidebar.tsx b/pkg/interface/src/views/components/Sidebar.tsx index 2b55537c8..79b14d6d9 100644 --- a/pkg/interface/src/views/components/Sidebar.tsx +++ b/pkg/interface/src/views/components/Sidebar.tsx @@ -5,6 +5,7 @@ import { Text, Icon, MenuItem as _MenuItem, + IconButton, } from "@tlon/indigo-react"; import { capitalize } from "lodash"; @@ -15,6 +16,12 @@ import { alphabeticalOrder } from "~/logic/lib/util"; import { GroupSwitcher } from "~/views/apps/groups/components/GroupSwitcher"; import { AppInvites, Associations, AppAssociations } from "~/types"; import { SidebarItem } from "./SidebarItem"; +import { + SidebarListHeader, + SidebarListConfig, + SidebarSort, +} from "./SidebarListHeader"; +import { useLocalStorageState } from "~/logic/lib/useLocalStorageState"; interface SidebarAppConfig { name: string; @@ -31,81 +38,51 @@ export type SidebarItemStatus = | "disconnected" | "loading"; -function ItemGroup(props: { - app: string; - apps: SidebarAppConfigs; - associations: Associations; - selected?: string; - group: string; -}) { - const { selected, apps, associations } = props; - const [open, setOpen] = useState(true); - - const toggleOpen = () => setOpen((o) => !o); - - const assoc = associations[props.app as AppName]; - - const items = _.pickBy( - assoc, - (value) => - value["group-path"] === props.group && value["app-name"] === props.app - ); - - if (Object.keys(items).length === 0) { - return null; - } - - return ( - - - - - {capitalize(props.app)} - - - {open && } - - ); -} - -const apps = ["chat", "publish", "link"]; -const GroupItems = (props: { - associations: Associations; - group: string; - apps: SidebarAppConfigs; - selected?: string; -}) => ( - <> - {apps.map((app) => ( - - ))} - -); - -function SidebarItems(props: { - apps: SidebarAppConfigs; - items: AppAssociations; - selected?: string; -}) { - const { items, associations, selected } = props; - - const ordered = Object.keys(items).sort((a, b) => { - const aAssoc = items[a]; - const bAssoc = items[b]; +function sidebarSort( + associations: AppAssociations +): Record number> { + const alphabetical = (a: string, b: string) => { + const aAssoc = associations[a]; + const bAssoc = associations[b]; const aTitle = aAssoc?.metadata?.title || b; const bTitle = bAssoc?.metadata?.title || b; return alphabeticalOrder(aTitle, bTitle); - }); + }; + + return { + asc: alphabetical, + desc: (a, b) => alphabetical(b, a), + }; +} + +const apps = ["chat", "publish", "link"]; + +function SidebarItems(props: { + apps: SidebarAppConfigs; + config: SidebarListConfig; + associations: Associations; + group: string; + selected?: string; +}) { + const { selected, group, config } = props; + const associations = { + ...props.associations.chat, + ...props.associations.publish, + ...props.associations.link, + }; + + const ordered = Object.keys(associations) + .filter((a) => { + const assoc = associations[a]; + return assoc["group-path"] === group; + }) + .sort(sidebarSort(associations)[config.sortBy]); return ( <> {ordered.map((path) => { - const assoc = items[path]; + const assoc = associations[path]; return ( ); })} @@ -122,6 +100,7 @@ function SidebarItems(props: { interface SidebarProps { children: ReactNode; + recentGroups: string[]; invites: AppInvites; api: GlobalApi; associations: Associations; @@ -139,6 +118,17 @@ export function Sidebar(props: SidebarProps) { if (!groupAsssociation) { return null; } + if (!associations) { + return null; + } + + const [config, setConfig] = useLocalStorageState( + `group-config:${props.selectedGroup}`, + { + sortBy: "asc", + hideUnjoined: false, + } + ); return ( - + {Object.keys(invites).map((appPath) => Object.keys(invites[appPath]).map((uid) => ( )) )} - + ); diff --git a/pkg/interface/src/views/components/SidebarItem.tsx b/pkg/interface/src/views/components/SidebarItem.tsx index 400219834..6d6aeb9b5 100644 --- a/pkg/interface/src/views/components/SidebarItem.tsx +++ b/pkg/interface/src/views/components/SidebarItem.tsx @@ -22,7 +22,15 @@ function SidebarItemIndicator(props: { status?: SidebarItemStatus }) { } } +const getAppIcon = (app: string) => { + if (app === "link") { + return "Links"; + } + return _.capitalize(app); +}; + export function SidebarItem(props: { + hideUnjoined: boolean; association: Association; path: string; selected: boolean; @@ -43,6 +51,12 @@ export function SidebarItem(props: { ? `/~groups${groupPath}/resource/${appName}${appPath}` : `/~groups${groupPath}/join/${appName}${appPath}`; + const color = selected ? 'black' : isSynced ? 'gray' : 'lightGray'; + + if(props.hideUnjoined && !isSynced) { + return null; + } + return ( - - {title} + + + + {title} + - ); } diff --git a/pkg/interface/src/views/components/SidebarListHeader.tsx b/pkg/interface/src/views/components/SidebarListHeader.tsx new file mode 100644 index 000000000..35b1c6245 --- /dev/null +++ b/pkg/interface/src/views/components/SidebarListHeader.tsx @@ -0,0 +1,60 @@ +import React, { useCallback } from "react"; +import * as Yup from "yup"; +import { Row, Box, Icon, Radio, Col, Checkbox } from "@tlon/indigo-react"; +import { FormikOnBlur } from "./FormikOnBlur"; +import { Dropdown } from "./Dropdown"; +import { FormikHelpers } from "formik"; + +export type SidebarSort = "asc" | "desc"; + +export interface SidebarListConfig { + sortBy: SidebarSort; + hideUnjoined: boolean; +} + +export function SidebarListHeader(props: { + initialValues: SidebarListConfig; + handleSubmit: (c: SidebarListConfig) => void; +}) { + const onSubmit = useCallback( + (values: SidebarListConfig, actions: FormikHelpers) => { + props.handleSubmit(values); + actions.setSubmitting(false); + }, + [props.handleSubmit] + ); + + return ( + + + {props.initialValues.hideUnjoined ? "Joined Channels" : "All Channels"} + + + + + + Sort Order + + + + + + + + + + } + > + + + + ); +} diff --git a/pkg/interface/src/views/components/Skeleton.tsx b/pkg/interface/src/views/components/Skeleton.tsx index 2a45b4e19..c3b92bbb9 100644 --- a/pkg/interface/src/views/components/Skeleton.tsx +++ b/pkg/interface/src/views/components/Skeleton.tsx @@ -10,8 +10,10 @@ import { Notebooks } from "~/types/publish-update"; import GlobalApi from "~/logic/api/global"; import { Path, AppName } from "~/types/noun"; import { LinkCollections } from "~/types/link-update"; +import styled from "styled-components"; interface SkeletonProps { children: ReactNode; + recentGroups: string[]; associations: Associations; chatSynced: ChatHookUpdate | null; linkListening: Set; @@ -31,6 +33,12 @@ const buntAppConfig = (name: string) => ({ getStatus: (s: string) => undefined, }); +const TruncatedBox = styled(Box)` + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; +`; + export function Skeleton(props: SkeletonProps) { const chatConfig = { name: "chat", @@ -108,6 +116,7 @@ export function Skeleton(props: SkeletonProps) { gridTemplateRows="32px 1fr" > {"<- Back"}
- {association?.metadata?.title} + {association?.metadata?.title} + + {association?.metadata?.description} +
{props.children}