open account data in same window instead of popup (#2234)
Some checks are pending
Deploy to Netlify (dev) / Deploy to Netlify (push) Waiting to run

* refactor TextViewer Content component

* open account data inside setting window

* close account data edit window on cancel when adding new
This commit is contained in:
Ajay Bura 2025-02-27 19:34:55 +11:00 committed by GitHub
parent b7e5e0db3e
commit 2b8b0dcffd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 429 additions and 383 deletions

View file

@ -31,8 +31,11 @@ export const TextViewerContent = style([
export const TextViewerPre = style([ export const TextViewerPre = style([
DefaultReset, DefaultReset,
{ {
padding: config.space.S600,
whiteSpace: 'pre-wrap', whiteSpace: 'pre-wrap',
wordBreak: 'break-word', wordBreak: 'break-word',
}, },
]); ]);
export const TextViewerPrePadding = style({
padding: config.space.S600,
});

View file

@ -1,5 +1,5 @@
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */ /* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
import React, { Suspense, lazy } from 'react'; import React, { ComponentProps, HTMLAttributes, Suspense, forwardRef, lazy } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { Box, Chip, Header, Icon, IconButton, Icons, Scroll, Text, as } from 'folds'; import { Box, Chip, Header, Icon, IconButton, Icons, Scroll, Text, as } from 'folds';
import { ErrorBoundary } from 'react-error-boundary'; import { ErrorBoundary } from 'react-error-boundary';
@ -8,6 +8,29 @@ import { copyToClipboard } from '../../utils/dom';
const ReactPrism = lazy(() => import('../../plugins/react-prism/ReactPrism')); const ReactPrism = lazy(() => import('../../plugins/react-prism/ReactPrism'));
type TextViewerContentProps = {
text: string;
langName: string;
size?: ComponentProps<typeof Text>['size'];
} & HTMLAttributes<HTMLPreElement>;
export const TextViewerContent = forwardRef<HTMLPreElement, TextViewerContentProps>(
({ text, langName, size, className, ...props }, ref) => (
<Text
as="pre"
size={size}
className={classNames(css.TextViewerPre, `language-${langName}`, className)}
{...props}
ref={ref}
>
<ErrorBoundary fallback={<code>{text}</code>}>
<Suspense fallback={<code>{text}</code>}>
<ReactPrism>{(codeRef) => <code ref={codeRef}>{text}</code>}</ReactPrism>
</Suspense>
</ErrorBoundary>
</Text>
)
);
export type TextViewerProps = { export type TextViewerProps = {
name: string; name: string;
text: string; text: string;
@ -43,6 +66,7 @@ export const TextViewer = as<'div', TextViewerProps>(
</Chip> </Chip>
</Box> </Box>
</Header> </Header>
<Box <Box
grow="Yes" grow="Yes"
className={css.TextViewerContent} className={css.TextViewerContent}
@ -50,13 +74,11 @@ export const TextViewer = as<'div', TextViewerProps>(
alignItems="Center" alignItems="Center"
> >
<Scroll hideTrack variant="Background" visibility="Hover"> <Scroll hideTrack variant="Background" visibility="Hover">
<Text as="pre" className={classNames(css.TextViewerPre, `language-${langName}`)}> <TextViewerContent
<ErrorBoundary fallback={<code>{text}</code>}> className={css.TextViewerPrePadding}
<Suspense fallback={<code>{text}</code>}> text={text}
<ReactPrism>{(codeRef) => <code ref={codeRef}>{text}</code>}</ReactPrism> langName={langName}
</Suspense> />
</ErrorBoundary>
</Text>
</Scroll> </Scroll>
</Box> </Box>
</Box> </Box>

View file

@ -0,0 +1,90 @@
import React, { useCallback, useState } from 'react';
import { Box, Text, Icon, Icons, Chip, Button } from 'folds';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useAccountDataCallback } from '../../../hooks/useAccountDataCallback';
type AccountDataProps = {
expand: boolean;
onExpandToggle: (expand: boolean) => void;
onSelect: (type: string | null) => void;
};
export function AccountData({ expand, onExpandToggle, onSelect }: AccountDataProps) {
const mx = useMatrixClient();
const [accountData, setAccountData] = useState(() => Array.from(mx.store.accountData.values()));
useAccountDataCallback(
mx,
useCallback(
() => setAccountData(Array.from(mx.store.accountData.values())),
[mx, setAccountData]
)
);
return (
<Box direction="Column" gap="100">
<Text size="L400">Account Data</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Global"
description="Data stored in your global account data."
after={
<Button
onClick={() => onExpandToggle(!expand)}
variant="Secondary"
fill="Soft"
size="300"
radii="300"
outlined
before={
<Icon src={expand ? Icons.ChevronTop : Icons.ChevronBottom} size="100" filled />
}
>
<Text size="B300">{expand ? 'Collapse' : 'Expand'}</Text>
</Button>
}
/>
{expand && (
<SettingTile>
<Box direction="Column" gap="200">
<Text size="L400">Types</Text>
<Box gap="200" wrap="Wrap">
<Chip
variant="Secondary"
fill="Soft"
radii="Pill"
before={<Icon size="50" src={Icons.Plus} />}
onClick={() => onSelect(null)}
>
<Text size="T200" truncate>
Add New
</Text>
</Chip>
{accountData.map((mEvent) => (
<Chip
key={mEvent.getType()}
variant="Secondary"
fill="Soft"
radii="Pill"
onClick={() => onSelect(mEvent.getType())}
>
<Text size="T200" truncate>
{mEvent.getType()}
</Text>
</Chip>
))}
</Box>
</Box>
</SettingTile>
)}
</SequenceCard>
</Box>
);
}

View file

@ -8,9 +8,7 @@ import React, {
useState, useState,
} from 'react'; } from 'react';
import { import {
as,
Box, Box,
Header,
Text, Text,
Icon, Icon,
Icons, Icons,
@ -20,6 +18,9 @@ import {
TextArea as TextAreaComponent, TextArea as TextAreaComponent,
color, color,
Spinner, Spinner,
Chip,
Scroll,
config,
} from 'folds'; } from 'folds';
import { isKeyHotkey } from 'is-hotkey'; import { isKeyHotkey } from 'is-hotkey';
import { MatrixError } from 'matrix-js-sdk'; import { MatrixError } from 'matrix-js-sdk';
@ -30,22 +31,28 @@ import { GetTarget } from '../../../plugins/text-area/type';
import { syntaxErrorPosition } from '../../../utils/dom'; import { syntaxErrorPosition } from '../../../utils/dom';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { Page, PageHeader } from '../../../components/page';
import { useAlive } from '../../../hooks/useAlive';
import { SequenceCard } from '../../../components/sequence-card';
import { TextViewerContent } from '../../../components/text-viewer';
const EDITOR_INTENT_SPACE_COUNT = 2; const EDITOR_INTENT_SPACE_COUNT = 2;
export type AccountDataEditorProps = { type AccountDataInfo = {
type?: string; type: string;
content?: object; content: object;
requestClose: () => void;
}; };
export const AccountDataEditor = as<'div', AccountDataEditorProps>( type AccountDataEditProps = {
({ type, content, requestClose, ...props }, ref) => { type: string;
defaultContent: string;
onCancel: () => void;
onSave: (info: AccountDataInfo) => void;
};
function AccountDataEdit({ type, defaultContent, onCancel, onSave }: AccountDataEditProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const defaultContent = useMemo( const alive = useAlive();
() => JSON.stringify(content, null, EDITOR_INTENT_SPACE_COUNT),
[content]
);
const textAreaRef = useRef<HTMLTextAreaElement>(null); const textAreaRef = useRef<HTMLTextAreaElement>(null);
const [jsonError, setJSONError] = useState<SyntaxError>(); const [jsonError, setJSONError] = useState<SyntaxError>();
@ -109,7 +116,14 @@ export const AccountDataEditor = as<'div', AccountDataEditorProps>(
return; return;
} }
submit(typeStr, parsedContent); submit(typeStr, parsedContent).then(() => {
if (alive()) {
onSave({
type: typeStr,
content: parsedContent,
});
}
});
}; };
useEffect(() => { useEffect(() => {
@ -121,28 +135,7 @@ export const AccountDataEditor = as<'div', AccountDataEditorProps>(
} }
}, [jsonError, operations, getTarget]); }, [jsonError, operations, getTarget]);
useEffect(() => {
if (submitState.status === AsyncStatus.Success) {
requestClose();
}
}, [submitState, requestClose]);
return ( return (
<Box grow="Yes" direction="Column" {...props} ref={ref}>
<Header className={css.EditorHeader} size="600">
<Box grow="Yes" gap="200">
<Box grow="Yes" alignItems="Center" gap="200">
<Text size="H3" truncate>
Account Data
</Text>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="Surface">
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Box>
</Header>
<Box <Box
as="form" as="form"
onSubmit={handleSubmit} onSubmit={handleSubmit}
@ -153,26 +146,39 @@ export const AccountDataEditor = as<'div', AccountDataEditorProps>(
aria-disabled={submitting} aria-disabled={submitting}
> >
<Box shrink="No" direction="Column" gap="100"> <Box shrink="No" direction="Column" gap="100">
<Text size="L400">Type</Text> <Text size="L400">Account Data</Text>
<Box gap="300"> <Box gap="300">
<Box grow="Yes" direction="Column"> <Box grow="Yes" direction="Column">
<Input <Input
variant={type.length > 0 || submitting ? 'SurfaceVariant' : 'Background'}
name="typeInput" name="typeInput"
size="400" size="400"
readOnly={!!type || submitting} radii="300"
readOnly={type.length > 0 || submitting}
defaultValue={type} defaultValue={type}
required required
/> />
</Box> </Box>
<Button <Button
variant="Primary" variant="Success"
size="400" size="400"
radii="300"
type="submit" type="submit"
disabled={submitting} disabled={submitting}
before={submitting && <Spinner variant="Primary" fill="Solid" size="300" />} before={submitting && <Spinner variant="Primary" fill="Solid" size="300" />}
> >
<Text size="B400">Save</Text> <Text size="B400">Save</Text>
</Button> </Button>
<Button
variant="Secondary"
fill="Soft"
size="400"
radii="300"
onClick={onCancel}
disabled={submitting}
>
<Text size="B400">Cancel</Text>
</Button>
</Box> </Box>
{submitState.status === AsyncStatus.Error && ( {submitState.status === AsyncStatus.Error && (
@ -205,7 +211,122 @@ export const AccountDataEditor = as<'div', AccountDataEditorProps>(
)} )}
</Box> </Box>
</Box> </Box>
);
}
type AccountDataViewProps = {
type: string;
defaultContent: string;
onEdit: () => void;
};
function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps) {
return (
<Box direction="Column" className={css.EditorContent} gap="400">
<Box shrink="No" gap="300" alignItems="End">
<Box grow="Yes" direction="Column" gap="100">
<Text size="L400">Account Data</Text>
<Input
variant="SurfaceVariant"
size="400"
radii="300"
readOnly
defaultValue={type}
required
/>
</Box>
<Button variant="Secondary" size="400" radii="300" onClick={onEdit}>
<Text size="B400">Edit</Text>
</Button>
</Box>
<Box grow="Yes" direction="Column" gap="100">
<Text size="L400">JSON Content</Text>
<SequenceCard variant="SurfaceVariant">
<Scroll visibility="Always" size="300" hideTrack>
<TextViewerContent
size="T300"
style={{
padding: `${config.space.S300} ${config.space.S100} ${config.space.S300} ${config.space.S300}`,
}}
text={defaultContent}
langName="JSON"
/>
</Scroll>
</SequenceCard>
</Box>
</Box> </Box>
); );
}
export type AccountDataEditorProps = {
type?: string;
requestClose: () => void;
};
export function AccountDataEditor({ type, requestClose }: AccountDataEditorProps) {
const mx = useMatrixClient();
const [data, setData] = useState<AccountDataInfo>({
type: type ?? '',
content: mx.getAccountData(type ?? '')?.getContent() ?? {},
});
const [edit, setEdit] = useState(!type);
const closeEdit = useCallback(() => {
if (!type) {
requestClose();
return;
} }
); setEdit(false);
}, [type, requestClose]);
const handleSave = useCallback((info: AccountDataInfo) => {
setData(info);
setEdit(false);
}, []);
const contentJSONStr = useMemo(
() => JSON.stringify(data.content, null, EDITOR_INTENT_SPACE_COUNT),
[data.content]
);
return (
<Page>
<PageHeader outlined={false} balance>
<Box alignItems="Center" grow="Yes" gap="200">
<Box alignItems="Inherit" grow="Yes" gap="200">
<Chip
size="500"
radii="Pill"
onClick={requestClose}
before={<Icon size="100" src={Icons.ArrowLeft} />}
>
<Text size="T300">Developer Tools</Text>
</Chip>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="Surface">
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Box>
</PageHeader>
<Box grow="Yes" direction="Column">
{edit ? (
<AccountDataEdit
type={data.type}
defaultContent={contentJSONStr}
onCancel={closeEdit}
onSave={handleSave}
/>
) : (
<AccountDataView
type={data.type}
defaultContent={contentJSONStr}
onEdit={() => setEdit(true)}
/>
)}
</Box>
</Page>
);
}

View file

@ -1,26 +1,5 @@
import React, { MouseEventHandler, useCallback, useState } from 'react'; import React, { useState } from 'react';
import { import { Box, Text, IconButton, Icon, Icons, Scroll, Switch, Button } from 'folds';
Box,
Text,
IconButton,
Icon,
Icons,
Scroll,
Switch,
Overlay,
OverlayBackdrop,
OverlayCenter,
Modal,
Chip,
Button,
PopOut,
RectCords,
Menu,
config,
MenuItem,
} from 'folds';
import { MatrixEvent } from 'matrix-js-sdk';
import FocusTrap from 'focus-trap-react';
import { Page, PageContent, PageHeader } from '../../../components/page'; import { Page, PageContent, PageHeader } from '../../../components/page';
import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css'; import { SequenceCardStyle } from '../styles.css';
@ -28,195 +7,9 @@ import { SettingTile } from '../../../components/setting-tile';
import { useSetting } from '../../../state/hooks/settings'; import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings'; import { settingsAtom } from '../../../state/settings';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useAccountDataCallback } from '../../../hooks/useAccountDataCallback';
import { TextViewer } from '../../../components/text-viewer';
import { stopPropagation } from '../../../utils/keyboard';
import { AccountDataEditor } from './AccountDataEditor'; import { AccountDataEditor } from './AccountDataEditor';
import { copyToClipboard } from '../../../utils/dom'; import { copyToClipboard } from '../../../utils/dom';
import { AccountData } from './AccountData';
function AccountData() {
const mx = useMatrixClient();
const [view, setView] = useState(false);
const [accountData, setAccountData] = useState(() => Array.from(mx.store.accountData.values()));
const [selectedEvent, selectEvent] = useState<MatrixEvent>();
const [menuCords, setMenuCords] = useState<RectCords>();
const [selectedOption, selectOption] = useState<'edit' | 'inspect'>();
useAccountDataCallback(
mx,
useCallback(
() => setAccountData(Array.from(mx.store.accountData.values())),
[mx, setAccountData]
)
);
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
const target = evt.currentTarget;
const eventType = target.getAttribute('data-event-type');
if (eventType) {
const mEvent = accountData.find((mEvt) => mEvt.getType() === eventType);
setMenuCords(evt.currentTarget.getBoundingClientRect());
selectEvent(mEvent);
}
};
const handleMenuClose = () => setMenuCords(undefined);
const handleEdit = () => {
selectOption('edit');
setMenuCords(undefined);
};
const handleInspect = () => {
selectOption('inspect');
setMenuCords(undefined);
};
const handleClose = useCallback(() => {
selectEvent(undefined);
selectOption(undefined);
}, []);
return (
<Box direction="Column" gap="100">
<Text size="L400">Account Data</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Global"
description="Data stored in your global account data."
after={
<Button
onClick={() => setView(!view)}
variant="Secondary"
fill="Soft"
size="300"
radii="300"
outlined
before={
<Icon src={view ? Icons.ChevronTop : Icons.ChevronBottom} size="100" filled />
}
>
<Text size="B300">{view ? 'Collapse' : 'Expand'}</Text>
</Button>
}
/>
{view && (
<SettingTile>
<Box direction="Column" gap="200">
<Text size="L400">Types</Text>
<Box gap="200" wrap="Wrap">
<Chip
variant="Secondary"
fill="Soft"
radii="Pill"
onClick={handleEdit}
before={<Icon size="50" src={Icons.Plus} />}
>
<Text size="T200" truncate>
Add New
</Text>
</Chip>
{accountData.map((mEvent) => (
<Chip
key={mEvent.getType()}
variant="Secondary"
fill="Soft"
radii="Pill"
aria-pressed={menuCords && selectedEvent?.getType() === mEvent.getType()}
onClick={handleMenu}
data-event-type={mEvent.getType()}
>
<Text size="T200" truncate>
{mEvent.getType()}
</Text>
</Chip>
))}
</Box>
</Box>
</SettingTile>
)}
<PopOut
anchor={menuCords}
offset={5}
position="Bottom"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: handleMenuClose,
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) =>
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
escapeDeactivates: stopPropagation,
}}
>
<Menu>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem size="300" variant="Surface" radii="300" onClick={handleInspect}>
<Text size="T300">Inspect</Text>
</MenuItem>
<MenuItem size="300" variant="Surface" radii="300" onClick={handleEdit}>
<Text size="T300">Edit</Text>
</MenuItem>
</Box>
</Menu>
</FocusTrap>
}
/>
</SequenceCard>
{selectedEvent && selectedOption === 'inspect' && (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: handleClose,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal variant="Surface" size="500">
<TextViewer
name={selectedEvent.getType() ?? 'Source Code'}
langName="json"
text={JSON.stringify(selectedEvent.getContent(), null, 2)}
requestClose={handleClose}
/>
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
)}
{selectedOption === 'edit' && (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: handleClose,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal variant="Surface" size="500">
<AccountDataEditor
type={selectedEvent?.getType()}
content={selectedEvent?.getContent()}
requestClose={handleClose}
/>
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
)}
</Box>
);
}
type DeveloperToolsProps = { type DeveloperToolsProps = {
requestClose: () => void; requestClose: () => void;
@ -224,6 +17,17 @@ type DeveloperToolsProps = {
export function DeveloperTools({ requestClose }: DeveloperToolsProps) { export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const [developerTools, setDeveloperTools] = useSetting(settingsAtom, 'developerTools'); const [developerTools, setDeveloperTools] = useSetting(settingsAtom, 'developerTools');
const [expand, setExpend] = useState(false);
const [accountDataType, setAccountDataType] = useState<string | null>();
if (accountDataType !== undefined) {
return (
<AccountDataEditor
type={accountDataType ?? undefined}
requestClose={() => setAccountDataType(undefined)}
/>
);
}
return ( return (
<Page> <Page>
@ -292,7 +96,13 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
</SequenceCard> </SequenceCard>
)} )}
</Box> </Box>
{developerTools && <AccountData />} {developerTools && (
<AccountData
expand={expand}
onExpandToggle={setExpend}
onSelect={setAccountDataType}
/>
)}
</Box> </Box>
</PageContent> </PageContent>
</Scroll> </Scroll>