-
Notifications
You must be signed in to change notification settings - Fork 5
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
✨ (core): Added new ListApps and ListAppsContinue commands
This PR also bring in a few updates to the default ConsoleLogger (mainly the max log level) as well as some sample app update to be able to test the commands we have developped (thanks to Olivier for the help)
- Loading branch information
1 parent
64841f9
commit f708627
Showing
32 changed files
with
1,327 additions
and
101 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
"@ledgerhq/device-sdk-core": minor | ||
--- | ||
|
||
Add new ListApps command to SDK core |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
"use client"; | ||
import React from "react"; | ||
|
||
import { CommandsView } from "@/components/CommandsView"; | ||
|
||
const DeviceActions: React.FC = () => { | ||
return <CommandsView />; | ||
}; | ||
|
||
export default DeviceActions; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,186 @@ | ||
import React, { useCallback, useEffect, useState } from "react"; | ||
|
||
import { | ||
Flex, | ||
Text, | ||
Icons, | ||
Drawer, | ||
Button, | ||
InfiniteLoader, | ||
} from "@ledgerhq/react-ui"; | ||
import styled from "styled-components"; | ||
import { CommandForm, ValueSelector } from "./CommandForm"; | ||
import { FieldType } from "@/hooks/useForm"; | ||
import { CommandResponse, CommandResponseProps } from "./CommandResponse"; | ||
|
||
const Wrapper = styled(Flex)` | ||
opacity: 0.8; | ||
&:hover { | ||
opacity: 1; | ||
} | ||
cursor: pointer; | ||
`; | ||
|
||
const Container = styled(Flex).attrs({ | ||
flexDirection: "column", | ||
backgroundColor: "opacityDefault.c05", | ||
p: 5, | ||
borderRadius: 2, | ||
rowGap: 4, | ||
})``; | ||
|
||
export type CommandProps< | ||
CommandArgs extends Record<string, FieldType> | void, | ||
Response, | ||
> = { | ||
title: string; | ||
description: string; | ||
sendCommand: (args: CommandArgs) => Promise<Response>; | ||
initialValues: CommandArgs; | ||
valueSelector?: ValueSelector<FieldType>; | ||
}; | ||
|
||
export function Command< | ||
CommandArgs extends Record<string, FieldType>, | ||
Response, | ||
>(props: CommandProps<CommandArgs, Response>) { | ||
const { title, description, initialValues, sendCommand, valueSelector } = | ||
props; | ||
|
||
const [values, setValues] = useState<CommandArgs>(initialValues); | ||
|
||
const [isOpen, setIsOpen] = React.useState(false); | ||
|
||
const [responses, setResponses] = useState<CommandResponseProps<Response>[]>( | ||
[], | ||
); | ||
|
||
const [loading, setLoading] = useState(false); | ||
|
||
const openDrawer = useCallback(() => { | ||
setIsOpen(true); | ||
}, []); | ||
|
||
const closeDrawer = useCallback(() => { | ||
setIsOpen(false); | ||
}, []); | ||
|
||
const handleClickSend = useCallback(() => { | ||
setLoading(true); | ||
setResponses((prev) => [ | ||
...prev, | ||
{ args: values, date: new Date(), response: null, loading: true }, | ||
]); | ||
sendCommand(values) | ||
.then((response) => { | ||
setResponses((prev) => [ | ||
...prev.slice(0, -1), | ||
{ args: values, date: new Date(), response, loading: false }, | ||
]); | ||
}) | ||
.catch((error) => { | ||
setResponses((prev) => [ | ||
...prev.slice(0, -1), | ||
{ args: values, date: new Date(), response: error, loading: false }, | ||
]); | ||
}) | ||
.finally(() => { | ||
setLoading(false); | ||
}); | ||
}, [values]); | ||
|
||
const handleClickClear = useCallback(() => { | ||
setResponses([]); | ||
}, []); | ||
|
||
const responseBoxRef = React.useRef<HTMLDivElement>(null); | ||
|
||
useEffect(() => { | ||
// scroll response box to bottom | ||
if (responseBoxRef.current) { | ||
responseBoxRef.current.scrollTop = responseBoxRef.current.scrollHeight; | ||
} | ||
}, [responses]); | ||
|
||
return ( | ||
<Wrapper | ||
flexDirection="row" | ||
alignItems="center" | ||
p={6} | ||
backgroundColor={"opacityDefault.c05"} | ||
borderRadius={2} | ||
onClick={openDrawer} | ||
> | ||
<Flex flex={1} flexDirection="column" rowGap={4}> | ||
<Text variant="large" fontWeight="semiBold"> | ||
{title} | ||
</Text> | ||
<Text variant="body" fontWeight="regular" color="opacityDefault.c60"> | ||
{description} | ||
</Text> | ||
</Flex> | ||
<Icons.ChevronRight size="M" color="opacityDefault.c50" /> | ||
<Drawer isOpen={isOpen} onClose={closeDrawer} big title={title}> | ||
<Flex flexDirection="column" rowGap={4} flex={1} overflowY="hidden"> | ||
<Text | ||
variant="body" | ||
fontWeight="regular" | ||
color="opacityDefault.c60" | ||
mb={5} | ||
> | ||
{description} | ||
</Text> | ||
<Container> | ||
<CommandForm | ||
initialValues={values} | ||
onChange={setValues} | ||
valueSelector={valueSelector} | ||
/> | ||
<Button | ||
variant="main" | ||
onClick={handleClickSend} | ||
disabled={loading} | ||
Icon={() => | ||
loading ? <InfiniteLoader size={20} /> : <Icons.ArrowRight /> | ||
} | ||
> | ||
Send | ||
</Button> | ||
</Container> | ||
<Container flex={1} overflowY="hidden"> | ||
<Flex | ||
ref={responseBoxRef} | ||
flexDirection="column" | ||
rowGap={4} | ||
flex={1} | ||
overflowY="scroll" | ||
> | ||
{responses.map(({ args, date, response, loading }, index) => ( | ||
<CommandResponse | ||
args={args} | ||
key={date.toISOString()} | ||
date={date} | ||
response={response} | ||
loading={loading} | ||
isLatest={index === responses.length - 1} | ||
/> | ||
))} | ||
</Flex> | ||
<Button | ||
variant="main" | ||
outline | ||
onClick={handleClickClear} | ||
disabled={responses.length === 0} | ||
> | ||
Clear responses | ||
</Button> | ||
</Container> | ||
</Flex> | ||
</Drawer> | ||
</Wrapper> | ||
); | ||
} | ||
|
||
export default Command; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,91 @@ | ||
import React, { useEffect } from "react"; | ||
import { Input, Flex, Switch, Text, SelectInput } from "@ledgerhq/react-ui"; | ||
import { useForm, FieldType } from "@/hooks/useForm"; | ||
|
||
export type ValueSelector<T extends FieldType> = Record< | ||
string, | ||
Array<{ | ||
label: string; | ||
value: T; | ||
}> | ||
>; | ||
|
||
export function getValueSelectorFromEnum< | ||
T extends Record<string, string | number>, | ||
>(enumObject: T) { | ||
const entries = Object.entries(enumObject); | ||
const res = entries.slice(entries.length / 2).map(([key, value]) => ({ | ||
label: key, | ||
value, | ||
})); | ||
return res; | ||
} | ||
|
||
export function CommandForm<Args extends Record<string, FieldType>>({ | ||
initialValues, | ||
onChange, | ||
valueSelector, | ||
}: { | ||
initialValues: Args; | ||
onChange: (values: Args) => void; | ||
valueSelector?: ValueSelector<FieldType>; | ||
}) { | ||
const { formValues, setFormValue } = useForm(initialValues); | ||
|
||
useEffect(() => { | ||
onChange(formValues); | ||
}, [formValues, onChange]); | ||
|
||
if (!formValues) return null; | ||
|
||
return ( | ||
<Flex flexDirection="column" flexWrap="wrap" rowGap={5} columnGap={5}> | ||
{Object.entries(formValues).map(([key, value]) => ( | ||
<Flex | ||
flexDirection="column" | ||
key={key} | ||
alignItems="flex-start" | ||
rowGap={3} | ||
columnGap={3} | ||
> | ||
<Text variant="paragraph" fontWeight="medium"> | ||
{key} | ||
</Text> | ||
{valueSelector?.[key] ? ( | ||
<Flex flexDirection="row" flexWrap="wrap" rowGap={2} columnGap={2}> | ||
<SelectInput | ||
placeholder={key} | ||
value={valueSelector[key].find((val) => val.value === value)} | ||
isMulti={false} | ||
onChange={(newVal) => newVal && setFormValue(key, newVal.value)} | ||
options={valueSelector[key]} | ||
isSearchable={false} | ||
/> | ||
</Flex> | ||
) : typeof value === "boolean" ? ( | ||
<Switch | ||
name="key" | ||
checked={value} | ||
onChange={() => setFormValue(key, !value)} | ||
/> | ||
) : typeof value === "string" ? ( | ||
<Input | ||
id={key} | ||
value={value} | ||
placeholder={key} | ||
onChange={(newVal) => setFormValue(key, newVal)} | ||
/> | ||
) : ( | ||
<Input | ||
id={key} | ||
value={value} | ||
placeholder={key} | ||
onChange={(newVal) => setFormValue(key, newVal ?? 0)} | ||
type="number" | ||
/> | ||
)} | ||
</Flex> | ||
))} | ||
</Flex> | ||
); | ||
} |
62 changes: 62 additions & 0 deletions
62
apps/sample/src/components/CommandsView/CommandResponse.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,62 @@ | ||
import React from "react"; | ||
import { Flex, InfiniteLoader, Text, Tooltip } from "@ledgerhq/react-ui"; | ||
import { SdkError } from "@ledgerhq/device-sdk-core/src/api/Error.js"; | ||
import { FieldType } from "@/hooks/useForm"; | ||
|
||
export type CommandResponseProps<Response> = { | ||
args: Record<string, FieldType>; | ||
date: Date; | ||
loading: boolean; | ||
response: Response | null; | ||
}; | ||
|
||
export function CommandResponse<Response>( | ||
props: CommandResponseProps<Response> & { isLatest: boolean }, | ||
) { | ||
const { args, date, loading, response, isLatest } = props; | ||
const responseString = JSON.stringify(response, null, 2); | ||
const isError = response instanceof Error || (response as SdkError)?._tag; | ||
return ( | ||
<Flex flexDirection="column" alignItems="flex-start"> | ||
<Tooltip | ||
placement="top" | ||
content={ | ||
<Text color="neutral.c00" whiteSpace="pre-wrap"> | ||
Arguments:{"\n"} | ||
{JSON.stringify(args, null, 2)} | ||
</Text> | ||
} | ||
> | ||
<Text | ||
variant="small" | ||
color="neutral.c60" | ||
fontWeight={isLatest ? "medium" : "regular"} | ||
flexGrow={0} | ||
> | ||
{date.toLocaleTimeString()} | ||
</Text> | ||
</Tooltip> | ||
{loading ? ( | ||
<InfiniteLoader size={20} /> | ||
) : ( | ||
<Text | ||
variant="body" | ||
fontWeight="regular" | ||
color={ | ||
isError | ||
? "error.c80" | ||
: responseString | ||
? "neutral.c100" | ||
: "neutral.c80" | ||
} | ||
style={{ | ||
fontStyle: responseString ? "normal" : "italic", | ||
whiteSpace: "pre-wrap", | ||
}} | ||
> | ||
{responseString ?? "void"} | ||
</Text> | ||
)} | ||
</Flex> | ||
); | ||
} |
Oops, something went wrong.