Skip to content

Commit

Permalink
Merge pull request #24 from player-ui/devtools/fileUploadAsset
Browse files Browse the repository at this point in the history
[Devtools/file upload] Asset
  • Loading branch information
lexfm authored May 16, 2024
2 parents 6655ff8 + 143973c commit cbb2eb4
Show file tree
Hide file tree
Showing 10 changed files with 238 additions and 10 deletions.
4 changes: 4 additions & 0 deletions docs/site/pages/assets/input.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

It provides an `Input` component that can be used to acquire data from the user.

The file type input is also available for uploading content by simply setting the `file` prop to `true`. The uploaded files will persist in the Player data as strings.

## Installation

To install `@devtools-ui/input`, you can use pnpm or yarn:
Expand Down Expand Up @@ -41,6 +43,8 @@ myFlow = {
<Input.Label>Label</Input.Label>
<Input.Note>Some note</Input.Note>
</Input>
/* File type Input */
<Input binding={b`contentBinding`} file={true} />
</MyView>,
],
};
Expand Down
1 change: 1 addition & 0 deletions docs/storybook/src/assets/Input.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ It provides an `Input` component that can be used to acquire data from the user.
| `size` | `"xs" | "sm" | "md" | "lg"` | false | Size of the Input height |
| `placeholder` | `string` | false | A text asset for the input's placeholder |
| `maxLength` | `number` | false | Max character length in the Input |
| `file` | `boolean` | false | For file uploader input |

## Basic Use Case

Expand Down
2 changes: 2 additions & 0 deletions docs/storybook/src/assets/Input.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,5 @@ const meta: Meta<typeof Input> = {
export default meta;

export const Basic = createDSLStory(() => import("../flows/input/basic?raw"));

export const File = createDSLStory(() => import("../flows/input/file?raw"));
42 changes: 42 additions & 0 deletions docs/storybook/src/flows/input/file.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import React from "react";
import { Input } from "@devtools-ui/plugin";
import { DSLFlow, makeBindingsForObject } from "@player-tools/dsl";

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const schema: any = {
fileContent: {
type: "StringType",
},
};

const bindings = makeBindingsForObject(schema);

const view1 = <Input binding={bindings.fileContent} file={true} />;

const flow: DSLFlow = {
id: "input-file",
views: [view1],
data: {
fileContent: "",
},
schema,
navigation: {
BEGIN: "FLOW_1",
FLOW_1: {
startState: "VIEW_1",
VIEW_1: {
state_type: "VIEW",
ref: view1,
transitions: {
"*": "END_Done",
},
},
END_Done: {
state_type: "END",
outcome: "DONE",
},
},
},
};

export default flow;
49 changes: 45 additions & 4 deletions input/src/components/InputComponent.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,61 @@
import React from "react";
import React, { useState, useRef } from "react";
import {
Input,
Button,
FormControl,
FormLabel,
FormHelperText,
FormErrorMessage,
} from "@chakra-ui/react";
import { TransformedInput } from "../types";
import { ReactAsset } from "@player-ui/react";
import { useInputAssetProps } from "./hooks";
import { useInputAssetProps, useFileInputAssetProps } from "../hooks";

const FileInputComponent = (props: TransformedInput) => {
const hiddenFileInput: React.Ref<any> = useRef(null);

const [fileName, setFileName] = useState("");

const handleClick = () => {
hiddenFileInput.current?.click();
};

const handleFile = (fileName: string) => {
setFileName(fileName);
};

const { id, label } = props;

const inputProps = useFileInputAssetProps({ ...props, handleFile });

return (
<FormControl>
<Button className="button-file" onClick={handleClick}>
<FormLabel style={{ margin: 0 }}>
{label ? <ReactAsset {...label.asset} /> : <>Select Content</>}
</FormLabel>
</Button>
<Input
id={id}
name={id}
{...inputProps}
style={{ display: "none" }}
ref={hiddenFileInput}
/>
{fileName ? <p>Uploaded file: {fileName}</p> : null}
</FormControl>
);
};

export const InputComponent = (props: TransformedInput) => {
const { validation, label, id, note, size, maxLength, placeholder } = props;
const { validation, label, id, note, size, maxLength, placeholder, file } =
props;

const inputProps = useInputAssetProps(props);

return (
return file ? (
<FileInputComponent {...props} />
) : (
<FormControl isInvalid={Boolean(validation)}>
{label && (
<FormLabel htmlFor={id}>
Expand Down
57 changes: 57 additions & 0 deletions input/src/components/__tests__/input.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import React from "react";
import { render, fireEvent } from "@testing-library/react";
import { describe, expect, test } from "vitest";
import { InputComponent } from "../index";
import { TransformedInput } from "../../types";

describe("InputComponent test", () => {
const inputAssetPropsMock: TransformedInput = {
id: "default-input",
type: "input",
set: () => {},
format: (value) => value,
value: "test",
binding: "some.binding",
};

const inputFileAssetPropsMock: TransformedInput = {
...inputAssetPropsMock,
id: "file-input",
file: true,
accept: [".txt"],
};

test("Renders default Input asset", () => {
const inputElement = render(<InputComponent {...inputAssetPropsMock} />);

const input = inputElement.container.querySelector(
"input"
) as HTMLInputElement;

fireEvent.change(input, { target: { value: "Test 2" } });

fireEvent.blur(input);

expect(input.value).toBe("Test 2");
});

test("Renders file type Input asset", () => {
const inputElement = render(
<InputComponent {...inputFileAssetPropsMock} />
);

const fileUploader = inputElement.container.querySelector(
"input"
) as HTMLInputElement;

const file = new File(['{"some":"content"}'], "content.json", {
type: "json",
});

fireEvent.change(fileUploader, { target: { files: [file] } });

expect(inputElement.container.querySelector("p")?.textContent).toBe(
"Uploaded file: content.json"
);
});
});
36 changes: 36 additions & 0 deletions input/src/dsl/__tests__/Input.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,40 @@ describe("DSL: Input", () => {
binding: "binding",
});
});

test("It should render a file uploader version of Input asset", async () => {
const rendered = await render(
<Input binding={b`binding`} file={true}>
<Input.Label>Label</Input.Label>
</Input>
);

expect(rendered.jsonValue).toStrictEqual({
id: "root",
type: "input",
file: true,
label: {
asset: {
id: "label",
type: "text",
value: "Label",
},
},
binding: "binding",
});
});

test("It should render a file uploader Input asset with accepted file extensions", async () => {
const rendered = await render(
<Input binding={b`binding`} file={true} accept={[".tsx", ".jsx"]} />
);

expect(rendered.jsonValue).toStrictEqual({
id: "root",
type: "input",
file: true,
accept: [".tsx", ".jsx"],
binding: "binding",
});
});
});
46 changes: 41 additions & 5 deletions input/src/components/hooks.ts → input/src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React from "react";
import React, { useState, useEffect, useRef } from "react";
import type { TransformedInput } from "../types";

// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down Expand Up @@ -43,8 +43,8 @@ export const useInputAssetProps = (
props: TransformedInput,
config?: InputHookConfig
) => {
const [localValue, setLocalValue] = React.useState(props.value ?? "");
const formatTimerRef = React.useRef<NodeJS.Timeout | undefined>(undefined);
const [localValue, setLocalValue] = useState(props.value ?? "");
const formatTimerRef = useRef<NodeJS.Timeout | undefined>(undefined);

const { formatDelay, decimalSymbol, prefix, suffix } = getConfig(config);

Expand Down Expand Up @@ -186,12 +186,12 @@ export const useInputAssetProps = (

// Update the stored value if data changes
const propsValue = props.value;
React.useEffect(() => {
useEffect(() => {
setLocalValue(formatValueWithAffix(propsValue));
}, [propsValue]);

/** clear anything pending on unmount of input */
React.useEffect(() => clearPending, []);
useEffect(() => clearPending, []);

return {
onBlur,
Expand All @@ -201,3 +201,39 @@ export const useInputAssetProps = (
value: localValue,
};
};

/** Props for file type Input */
export const useFileInputAssetProps = (props: TransformedInput) => {
const { accept } = props;

const acceptedExtensions = accept
? accept.concat(".json").join(", ")
: ".json";

/** Parses file content for upload into a string if file type Input */
const onFileUpload: React.ChangeEventHandler = (e): void => {
const fileList = (<HTMLInputElement>e.target).files;
const file = fileList ? fileList[0] : "";
const reader = new FileReader();

reader.addEventListener(
"load",
() => {
// this will set the file contents in the data model
props.set(reader.result as string);
},
false
);

if (file) {
reader.readAsText(file);
props.handleFile && props.handleFile(file.name);
}
};

return {
type: "file",
onChange: onFileUpload,
accept: acceptedExtensions,
};
};
9 changes: 9 additions & 0 deletions input/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ export interface InputAsset extends Asset<"input"> {

/** Max character length in the Input */
maxLength?: number;

/** For file uploader Input */
file?: boolean;

/** File extensions to be accepted by the file uploader Input, .json supported by default */
accept?: string[];
}

export interface TransformedInput extends InputAsset {
Expand All @@ -41,4 +47,7 @@ export interface TransformedInput extends InputAsset {

/** The dataType defined from the schema */
dataType?: Schema.DataType;

/** Handler for persisting file name for file type input */
handleFile?: (name: string) => void;
}
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"importHelpers": true,
"resolveJsonModule": true,
"composite": true,
"lib": ["DOM", "ES2020"],
"lib": ["DOM", "DOM.Iterable", "ES2020"],
"typeRoots": ["./typings", "./node_modules/@types"]
}
}

0 comments on commit cbb2eb4

Please sign in to comment.