Skip to content

Commit

Permalink
✨ (core): Added new ListApps and ListAppsContinue commands
Browse files Browse the repository at this point in the history
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
ofreyssinet-ledger committed Jun 24, 2024
1 parent 64841f9 commit f708627
Show file tree
Hide file tree
Showing 32 changed files with 1,327 additions and 101 deletions.
5 changes: 5 additions & 0 deletions .changeset/odd-ties-count.md
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
10 changes: 10 additions & 0 deletions apps/sample/src/app/commands/page.tsx
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;
186 changes: 186 additions & 0 deletions apps/sample/src/components/CommandsView/Command.tsx
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;
91 changes: 91 additions & 0 deletions apps/sample/src/components/CommandsView/CommandForm.tsx
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 apps/sample/src/components/CommandsView/CommandResponse.tsx
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>
);
}
Loading

0 comments on commit f708627

Please sign in to comment.