mirror of
https://github.com/cinnyapp/cinny.git
synced 2025-03-12 06:00:01 +01:00
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
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:
parent
b7e5e0db3e
commit
2b8b0dcffd
5 changed files with 429 additions and 383 deletions
|
@ -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,
|
||||||
|
});
|
||||||
|
|
|
@ -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>
|
||||||
|
|
90
src/app/features/settings/developer-tools/AccountData.tsx
Normal file
90
src/app/features/settings/developer-tools/AccountData.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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,182 +31,302 @@ 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;
|
||||||
|
|
||||||
|
type AccountDataInfo = {
|
||||||
|
type: string;
|
||||||
|
content: object;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AccountDataEditProps = {
|
||||||
|
type: string;
|
||||||
|
defaultContent: string;
|
||||||
|
onCancel: () => void;
|
||||||
|
onSave: (info: AccountDataInfo) => void;
|
||||||
|
};
|
||||||
|
function AccountDataEdit({ type, defaultContent, onCancel, onSave }: AccountDataEditProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const alive = useAlive();
|
||||||
|
|
||||||
|
const textAreaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const [jsonError, setJSONError] = useState<SyntaxError>();
|
||||||
|
|
||||||
|
const getTarget: GetTarget = useCallback(() => {
|
||||||
|
const target = textAreaRef.current;
|
||||||
|
if (!target) throw new Error('TextArea element not found!');
|
||||||
|
return target;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { textArea, operations, intent } = useMemo(() => {
|
||||||
|
const ta = new TextArea(getTarget);
|
||||||
|
const op = new TextAreaOperations(getTarget);
|
||||||
|
return {
|
||||||
|
textArea: ta,
|
||||||
|
operations: op,
|
||||||
|
intent: new Intent(EDITOR_INTENT_SPACE_COUNT, ta, op),
|
||||||
|
};
|
||||||
|
}, [getTarget]);
|
||||||
|
|
||||||
|
const intentHandler = useTextAreaIntentHandler(textArea, operations, intent);
|
||||||
|
|
||||||
|
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (evt) => {
|
||||||
|
intentHandler(evt);
|
||||||
|
if (isKeyHotkey('escape', evt)) {
|
||||||
|
const cursor = Cursor.fromTextAreaElement(getTarget());
|
||||||
|
operations.deselect(cursor);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const [submitState, submit] = useAsyncCallback<object, MatrixError, [string, object]>(
|
||||||
|
useCallback((dataType, data) => mx.setAccountData(dataType, data), [mx])
|
||||||
|
);
|
||||||
|
const submitting = submitState.status === AsyncStatus.Loading;
|
||||||
|
|
||||||
|
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
if (submitting) return;
|
||||||
|
|
||||||
|
const target = evt.target as HTMLFormElement | undefined;
|
||||||
|
const typeInput = target?.typeInput as HTMLInputElement | undefined;
|
||||||
|
const contentTextArea = target?.contentTextArea as HTMLTextAreaElement | undefined;
|
||||||
|
if (!typeInput || !contentTextArea) return;
|
||||||
|
|
||||||
|
const typeStr = typeInput.value.trim();
|
||||||
|
const contentStr = contentTextArea.value.trim();
|
||||||
|
|
||||||
|
let parsedContent: object;
|
||||||
|
try {
|
||||||
|
parsedContent = JSON.parse(contentStr);
|
||||||
|
} catch (e) {
|
||||||
|
setJSONError(e as SyntaxError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setJSONError(undefined);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!typeStr ||
|
||||||
|
parsedContent === null ||
|
||||||
|
defaultContent === JSON.stringify(parsedContent, null, EDITOR_INTENT_SPACE_COUNT)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
submit(typeStr, parsedContent).then(() => {
|
||||||
|
if (alive()) {
|
||||||
|
onSave({
|
||||||
|
type: typeStr,
|
||||||
|
content: parsedContent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (jsonError) {
|
||||||
|
const errorPosition = syntaxErrorPosition(jsonError) ?? 0;
|
||||||
|
const cursor = new Cursor(errorPosition, errorPosition, 'none');
|
||||||
|
operations.select(cursor);
|
||||||
|
getTarget()?.focus();
|
||||||
|
}
|
||||||
|
}, [jsonError, operations, getTarget]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
as="form"
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
grow="Yes"
|
||||||
|
className={css.EditorContent}
|
||||||
|
direction="Column"
|
||||||
|
gap="400"
|
||||||
|
aria-disabled={submitting}
|
||||||
|
>
|
||||||
|
<Box shrink="No" direction="Column" gap="100">
|
||||||
|
<Text size="L400">Account Data</Text>
|
||||||
|
<Box gap="300">
|
||||||
|
<Box grow="Yes" direction="Column">
|
||||||
|
<Input
|
||||||
|
variant={type.length > 0 || submitting ? 'SurfaceVariant' : 'Background'}
|
||||||
|
name="typeInput"
|
||||||
|
size="400"
|
||||||
|
radii="300"
|
||||||
|
readOnly={type.length > 0 || submitting}
|
||||||
|
defaultValue={type}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
variant="Success"
|
||||||
|
size="400"
|
||||||
|
radii="300"
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
before={submitting && <Spinner variant="Primary" fill="Solid" size="300" />}
|
||||||
|
>
|
||||||
|
<Text size="B400">Save</Text>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
size="400"
|
||||||
|
radii="300"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
<Text size="B400">Cancel</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{submitState.status === AsyncStatus.Error && (
|
||||||
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||||
|
<b>{submitState.error.message}</b>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box grow="Yes" direction="Column" gap="100">
|
||||||
|
<Box shrink="No">
|
||||||
|
<Text size="L400">JSON Content</Text>
|
||||||
|
</Box>
|
||||||
|
<TextAreaComponent
|
||||||
|
ref={textAreaRef}
|
||||||
|
name="contentTextArea"
|
||||||
|
className={css.EditorTextArea}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
defaultValue={defaultContent}
|
||||||
|
resize="None"
|
||||||
|
spellCheck="false"
|
||||||
|
required
|
||||||
|
readOnly={submitting}
|
||||||
|
/>
|
||||||
|
{jsonError && (
|
||||||
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||||
|
<b>
|
||||||
|
{jsonError.name}: {jsonError.message}
|
||||||
|
</b>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export type AccountDataEditorProps = {
|
export type AccountDataEditorProps = {
|
||||||
type?: string;
|
type?: string;
|
||||||
content?: object;
|
|
||||||
requestClose: () => void;
|
requestClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AccountDataEditor = as<'div', AccountDataEditorProps>(
|
export function AccountDataEditor({ type, requestClose }: AccountDataEditorProps) {
|
||||||
({ type, content, requestClose, ...props }, ref) => {
|
const mx = useMatrixClient();
|
||||||
const mx = useMatrixClient();
|
|
||||||
const defaultContent = useMemo(
|
|
||||||
() => JSON.stringify(content, null, EDITOR_INTENT_SPACE_COUNT),
|
|
||||||
[content]
|
|
||||||
);
|
|
||||||
const textAreaRef = useRef<HTMLTextAreaElement>(null);
|
|
||||||
const [jsonError, setJSONError] = useState<SyntaxError>();
|
|
||||||
|
|
||||||
const getTarget: GetTarget = useCallback(() => {
|
const [data, setData] = useState<AccountDataInfo>({
|
||||||
const target = textAreaRef.current;
|
type: type ?? '',
|
||||||
if (!target) throw new Error('TextArea element not found!');
|
content: mx.getAccountData(type ?? '')?.getContent() ?? {},
|
||||||
return target;
|
});
|
||||||
}, []);
|
|
||||||
|
|
||||||
const { textArea, operations, intent } = useMemo(() => {
|
const [edit, setEdit] = useState(!type);
|
||||||
const ta = new TextArea(getTarget);
|
|
||||||
const op = new TextAreaOperations(getTarget);
|
|
||||||
return {
|
|
||||||
textArea: ta,
|
|
||||||
operations: op,
|
|
||||||
intent: new Intent(EDITOR_INTENT_SPACE_COUNT, ta, op),
|
|
||||||
};
|
|
||||||
}, [getTarget]);
|
|
||||||
|
|
||||||
const intentHandler = useTextAreaIntentHandler(textArea, operations, intent);
|
const closeEdit = useCallback(() => {
|
||||||
|
if (!type) {
|
||||||
|
requestClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEdit(false);
|
||||||
|
}, [type, requestClose]);
|
||||||
|
|
||||||
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (evt) => {
|
const handleSave = useCallback((info: AccountDataInfo) => {
|
||||||
intentHandler(evt);
|
setData(info);
|
||||||
if (isKeyHotkey('escape', evt)) {
|
setEdit(false);
|
||||||
const cursor = Cursor.fromTextAreaElement(getTarget());
|
}, []);
|
||||||
operations.deselect(cursor);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const [submitState, submit] = useAsyncCallback<object, MatrixError, [string, object]>(
|
const contentJSONStr = useMemo(
|
||||||
useCallback((dataType, data) => mx.setAccountData(dataType, data), [mx])
|
() => JSON.stringify(data.content, null, EDITOR_INTENT_SPACE_COUNT),
|
||||||
);
|
[data.content]
|
||||||
const submitting = submitState.status === AsyncStatus.Loading;
|
);
|
||||||
|
|
||||||
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
return (
|
||||||
evt.preventDefault();
|
<Page>
|
||||||
if (submitting) return;
|
<PageHeader outlined={false} balance>
|
||||||
|
<Box alignItems="Center" grow="Yes" gap="200">
|
||||||
const target = evt.target as HTMLFormElement | undefined;
|
<Box alignItems="Inherit" grow="Yes" gap="200">
|
||||||
const typeInput = target?.typeInput as HTMLInputElement | undefined;
|
<Chip
|
||||||
const contentTextArea = target?.contentTextArea as HTMLTextAreaElement | undefined;
|
size="500"
|
||||||
if (!typeInput || !contentTextArea) return;
|
radii="Pill"
|
||||||
|
onClick={requestClose}
|
||||||
const typeStr = typeInput.value.trim();
|
before={<Icon size="100" src={Icons.ArrowLeft} />}
|
||||||
const contentStr = contentTextArea.value.trim();
|
>
|
||||||
|
<Text size="T300">Developer Tools</Text>
|
||||||
let parsedContent: object;
|
</Chip>
|
||||||
try {
|
|
||||||
parsedContent = JSON.parse(contentStr);
|
|
||||||
} catch (e) {
|
|
||||||
setJSONError(e as SyntaxError);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setJSONError(undefined);
|
|
||||||
|
|
||||||
if (
|
|
||||||
!typeStr ||
|
|
||||||
parsedContent === null ||
|
|
||||||
defaultContent === JSON.stringify(parsedContent, null, EDITOR_INTENT_SPACE_COUNT)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
submit(typeStr, parsedContent);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (jsonError) {
|
|
||||||
const errorPosition = syntaxErrorPosition(jsonError) ?? 0;
|
|
||||||
const cursor = new Cursor(errorPosition, errorPosition, 'none');
|
|
||||||
operations.select(cursor);
|
|
||||||
getTarget()?.focus();
|
|
||||||
}
|
|
||||||
}, [jsonError, operations, getTarget]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (submitState.status === AsyncStatus.Success) {
|
|
||||||
requestClose();
|
|
||||||
}
|
|
||||||
}, [submitState, requestClose]);
|
|
||||||
|
|
||||||
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>
|
</Box>
|
||||||
</Header>
|
<Box shrink="No">
|
||||||
<Box
|
<IconButton onClick={requestClose} variant="Surface">
|
||||||
as="form"
|
<Icon src={Icons.Cross} />
|
||||||
onSubmit={handleSubmit}
|
</IconButton>
|
||||||
grow="Yes"
|
|
||||||
className={css.EditorContent}
|
|
||||||
direction="Column"
|
|
||||||
gap="400"
|
|
||||||
aria-disabled={submitting}
|
|
||||||
>
|
|
||||||
<Box shrink="No" direction="Column" gap="100">
|
|
||||||
<Text size="L400">Type</Text>
|
|
||||||
<Box gap="300">
|
|
||||||
<Box grow="Yes" direction="Column">
|
|
||||||
<Input
|
|
||||||
name="typeInput"
|
|
||||||
size="400"
|
|
||||||
readOnly={!!type || submitting}
|
|
||||||
defaultValue={type}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
<Button
|
|
||||||
variant="Primary"
|
|
||||||
size="400"
|
|
||||||
type="submit"
|
|
||||||
disabled={submitting}
|
|
||||||
before={submitting && <Spinner variant="Primary" fill="Solid" size="300" />}
|
|
||||||
>
|
|
||||||
<Text size="B400">Save</Text>
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{submitState.status === AsyncStatus.Error && (
|
|
||||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
|
||||||
<b>{submitState.error.message}</b>
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
<Box grow="Yes" direction="Column" gap="100">
|
|
||||||
<Box shrink="No">
|
|
||||||
<Text size="L400">JSON Content</Text>
|
|
||||||
</Box>
|
|
||||||
<TextAreaComponent
|
|
||||||
ref={textAreaRef}
|
|
||||||
name="contentTextArea"
|
|
||||||
className={css.EditorTextArea}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
defaultValue={defaultContent}
|
|
||||||
resize="None"
|
|
||||||
spellCheck="false"
|
|
||||||
required
|
|
||||||
readOnly={submitting}
|
|
||||||
/>
|
|
||||||
{jsonError && (
|
|
||||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
|
||||||
<b>
|
|
||||||
{jsonError.name}: {jsonError.message}
|
|
||||||
</b>
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
</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>
|
</Box>
|
||||||
);
|
</Page>
|
||||||
}
|
);
|
||||||
);
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
Loading…
Reference in a new issue