From 27a78523103368f8a36bbb03890830938d26c770 Mon Sep 17 00:00:00 2001 From: alexisvisco Date: Wed, 10 Apr 2019 16:38:26 +0200 Subject: [PATCH 01/28] auto completion work --- src/components/Container/NewContainer.tsx | 4 +- src/components/Form/AutocompleteInput.tsx | 60 +++++++++++++++++++++++ src/components/Form/Input.tsx | 1 - 3 files changed, 62 insertions(+), 3 deletions(-) create mode 100644 src/components/Form/AutocompleteInput.tsx diff --git a/src/components/Container/NewContainer.tsx b/src/components/Container/NewContainer.tsx index 046fc21..57e9318 100644 --- a/src/components/Container/NewContainer.tsx +++ b/src/components/Container/NewContainer.tsx @@ -3,6 +3,7 @@ import { Header } from "../Layout" import { Button, Form, FormGroup, FormSection, Input, Select } from "../Form" import { Col, Container, Row } from "../Responsive" import { useForm } from "../../hooks" +import AutocompleteInput from "../Form/AutocompleteInput"; const test = () => new Promise((resolve, reject) => setTimeout(resolve, 4000)) @@ -36,8 +37,7 @@ export default () => { - + diff --git a/src/components/Form/AutocompleteInput.tsx b/src/components/Form/AutocompleteInput.tsx new file mode 100644 index 0000000..414dcf7 --- /dev/null +++ b/src/components/Form/AutocompleteInput.tsx @@ -0,0 +1,60 @@ +import { styled, theme } from "../../style" +import { Input } from "./Input"; + +import React, { ChangeEvent, useState } from "react"; + +const AutocompleteInput = styled(Input)` +` + +const AutocompleteItems = styled.div` + margin-top: 10px; + border-radius: 0.25rem; + position: absolute; + z-index: 99; + border: 1px solid ${theme.color.grey}; +` + +const AutocompleteItem = styled.div` + cursor: pointer; + background: white; + padding: 0.3rem 0.8rem 0.3rem 0.8rem; + :hover{ + background: ${theme.color.grey}; + } +` + +type event = ChangeEvent + +interface IProps { + suggestions: string[] + tags?: boolean, + onChange: (event: event) => void + name: string +} + +export default ({ name, onChange, suggestions, tags = false }: IProps) => { + + let [completions, setCompletions] = useState([] as string[]) + let [value, setValue] = useState("") + + const change = (e: event) => { + setValue(e.target.value) + setCompletions(suggestions.filter(x => e.target.value.length > 0 && x.startsWith(e.target.value))) + onChange(e) + } + + return ( +
+ + { + completions.length > 0 + ? + {completions.map((value, index) => ( { setValue(value); setCompletions([]) } } key={index}>{value}))} + + : <> + } + +
+ ) +} + diff --git a/src/components/Form/Input.tsx b/src/components/Form/Input.tsx index 455d452..516ac5e 100644 --- a/src/components/Form/Input.tsx +++ b/src/components/Form/Input.tsx @@ -1,6 +1,5 @@ import { styled } from "../../style" - export const Input = styled.input` display: block; width: 100%; From dc8fbbb57601de035b9aa13c65a519e68435d614 Mon Sep 17 00:00:00 2001 From: alexisvisco Date: Wed, 10 Apr 2019 17:30:25 +0200 Subject: [PATCH 02/28] add esc sequence to hide auto completions. add event to known if the user click outside of the input, then hide auto completions. --- src/components/Form/AutocompleteInput.tsx | 45 ++++++++++++++++++++--- 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/src/components/Form/AutocompleteInput.tsx b/src/components/Form/AutocompleteInput.tsx index 414dcf7..9c00647 100644 --- a/src/components/Form/AutocompleteInput.tsx +++ b/src/components/Form/AutocompleteInput.tsx @@ -1,7 +1,7 @@ import { styled, theme } from "../../style" import { Input } from "./Input"; -import React, { ChangeEvent, useState } from "react"; +import React, { ChangeEvent, FocusEventHandler, KeyboardEventHandler, useEffect, useState } from "react"; const AutocompleteInput = styled(Input)` ` @@ -37,23 +37,56 @@ export default ({ name, onChange, suggestions, tags = false }: IProps) => { let [completions, setCompletions] = useState([] as string[]) let [value, setValue] = useState("") + useEffect(() => { + const blur = (ev: any) => { + if (!Array.from(document.getElementsByClassName("autocomplete-item")).some(e => e === ev)) { + setCompletions([]) + } + } + document.addEventListener("click", blur) + return () => document.removeEventListener("click", blur) + }) + const change = (e: event) => { setValue(e.target.value) setCompletions(suggestions.filter(x => e.target.value.length > 0 && x.startsWith(e.target.value))) onChange(e) } + const select = (val: string) => { + console.log("value", val) + setCompletions([]) + setValue(val); + } + + const key = (e: any) => { + console.log(e.keyCode) + switch (e.keyCode) { + // esc + case 27: + setCompletions([]) + break + // down + case 40: + // up + case 38: + } + } + return ( -
- +
+ { completions.length > 0 - ? - {completions.map((value, index) => ( { setValue(value); setCompletions([]) } } key={index}>{value}))} + ? + + {completions.map((val, index) => ( { + console.log(val) + select(val) + }} className={"autocomplete-item"} key={index}>{val}))} : <> } -
) } From 5b8718843ba529454dfe37d1f07aad614e37a4fa Mon Sep 17 00:00:00 2001 From: alexisvisco Date: Wed, 10 Apr 2019 17:49:13 +0200 Subject: [PATCH 03/28] add arrow navigation. --- src/components/Form/AutocompleteInput.tsx | 49 +++++++++++++---------- 1 file changed, 27 insertions(+), 22 deletions(-) diff --git a/src/components/Form/AutocompleteInput.tsx b/src/components/Form/AutocompleteInput.tsx index 9c00647..8973b93 100644 --- a/src/components/Form/AutocompleteInput.tsx +++ b/src/components/Form/AutocompleteInput.tsx @@ -36,13 +36,10 @@ export default ({ name, onChange, suggestions, tags = false }: IProps) => { let [completions, setCompletions] = useState([] as string[]) let [value, setValue] = useState("") + let [selectedIndex, setSelectedIndex] = useState(-1) useEffect(() => { - const blur = (ev: any) => { - if (!Array.from(document.getElementsByClassName("autocomplete-item")).some(e => e === ev)) { - setCompletions([]) - } - } + const blur = (_: any) => hide() document.addEventListener("click", blur) return () => document.removeEventListener("click", blur) }) @@ -54,23 +51,27 @@ export default ({ name, onChange, suggestions, tags = false }: IProps) => { } const select = (val: string) => { - console.log("value", val) - setCompletions([]) setValue(val); + hide() + } + + const hide = () => { + setSelectedIndex(-1) + setCompletions([]) } + + const key = (e: any) => { - console.log(e.keyCode) - switch (e.keyCode) { - // esc - case 27: - setCompletions([]) - break - // down - case 40: - // up - case 38: - } + + const down = () => setSelectedIndex(selectedIndex + 1 > completions.length - 1 ? 0 : selectedIndex + 1) + const up = () => setSelectedIndex(selectedIndex + 1 > completions.length - 1 ? 0 : selectedIndex + 1) + + if (e.keyCode === 27) hide() + else if (e.keyCode === 40) down() + else if (e.keyCode === 38) up() + else if (e.keyCode === 13) select(completions[selectedIndex]) //todo change this ? + } return ( @@ -80,10 +81,14 @@ export default ({ name, onChange, suggestions, tags = false }: IProps) => { completions.length > 0 ? - {completions.map((val, index) => ( { - console.log(val) - select(val) - }} className={"autocomplete-item"} key={index}>{val}))} + {completions.map((val, index) => { + if (index !== selectedIndex) + return ( select(val)} key={index}>{val}) + else + return ( select(val)} + style={{ background: theme.color.grey}} key={index}>{val}) + } + )} : <> } From 98654c7955c1aa463afd8fcb708eb3f4d706350a Mon Sep 17 00:00:00 2001 From: alexisvisco Date: Wed, 10 Apr 2019 19:41:16 +0200 Subject: [PATCH 04/28] change let to const. --- src/components/Form/AutocompleteInput.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/components/Form/AutocompleteInput.tsx b/src/components/Form/AutocompleteInput.tsx index 8973b93..057b00e 100644 --- a/src/components/Form/AutocompleteInput.tsx +++ b/src/components/Form/AutocompleteInput.tsx @@ -34,9 +34,9 @@ interface IProps { export default ({ name, onChange, suggestions, tags = false }: IProps) => { - let [completions, setCompletions] = useState([] as string[]) - let [value, setValue] = useState("") - let [selectedIndex, setSelectedIndex] = useState(-1) + const [completions, setCompletions] = useState([]) + const [value, setValue] = useState("") + const [selectedIndex, setSelectedIndex] = useState(-1) useEffect(() => { const blur = (_: any) => hide() @@ -61,16 +61,17 @@ export default ({ name, onChange, suggestions, tags = false }: IProps) => { } - const key = (e: any) => { - const down = () => setSelectedIndex(selectedIndex + 1 > completions.length - 1 ? 0 : selectedIndex + 1) const up = () => setSelectedIndex(selectedIndex + 1 > completions.length - 1 ? 0 : selectedIndex + 1) if (e.keyCode === 27) hide() else if (e.keyCode === 40) down() else if (e.keyCode === 38) up() - else if (e.keyCode === 13) select(completions[selectedIndex]) //todo change this ? + else if (e.keyCode === 13) { + e.preventDefault() + select(completions[selectedIndex]) + } } From 14d85c7bb7b96e14db602f9ae8f30bfd3ad5948a Mon Sep 17 00:00:00 2001 From: alexisvisco Date: Wed, 10 Apr 2019 19:48:10 +0200 Subject: [PATCH 05/28] refactor event. refactor style. --- src/components/Form/AutocompleteInput.tsx | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/src/components/Form/AutocompleteInput.tsx b/src/components/Form/AutocompleteInput.tsx index 057b00e..5c6c629 100644 --- a/src/components/Form/AutocompleteInput.tsx +++ b/src/components/Form/AutocompleteInput.tsx @@ -23,12 +23,10 @@ const AutocompleteItem = styled.div` } ` -type event = ChangeEvent - interface IProps { suggestions: string[] tags?: boolean, - onChange: (event: event) => void + onChange: (event: ChangeEvent) => void name: string } @@ -44,7 +42,7 @@ export default ({ name, onChange, suggestions, tags = false }: IProps) => { return () => document.removeEventListener("click", blur) }) - const change = (e: event) => { + const change = (e: ChangeEvent) => { setValue(e.target.value) setCompletions(suggestions.filter(x => e.target.value.length > 0 && x.startsWith(e.target.value))) onChange(e) @@ -82,14 +80,10 @@ export default ({ name, onChange, suggestions, tags = false }: IProps) => { completions.length > 0 ? - {completions.map((val, index) => { - if (index !== selectedIndex) - return ( select(val)} key={index}>{val}) - else - return ( select(val)} - style={{ background: theme.color.grey}} key={index}>{val}) - } - )} + {completions.map((val, index) => + ( select(val)} key={index}>{val})) + } : <> } From e1582729a517860c38064bda02d1d1807f7af856 Mon Sep 17 00:00:00 2001 From: alexisvisco Date: Wed, 10 Apr 2019 19:49:34 +0200 Subject: [PATCH 06/28] tab hide. --- src/components/Form/AutocompleteInput.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Form/AutocompleteInput.tsx b/src/components/Form/AutocompleteInput.tsx index 5c6c629..6428d36 100644 --- a/src/components/Form/AutocompleteInput.tsx +++ b/src/components/Form/AutocompleteInput.tsx @@ -63,7 +63,7 @@ export default ({ name, onChange, suggestions, tags = false }: IProps) => { const down = () => setSelectedIndex(selectedIndex + 1 > completions.length - 1 ? 0 : selectedIndex + 1) const up = () => setSelectedIndex(selectedIndex + 1 > completions.length - 1 ? 0 : selectedIndex + 1) - if (e.keyCode === 27) hide() + if (e.keyCode === 27 || e.keyCode === 9) hide() else if (e.keyCode === 40) down() else if (e.keyCode === 38) up() else if (e.keyCode === 13) { From 2c9258e9990fbd6df74a664a512acae8a14b6fe5 Mon Sep 17 00:00:00 2001 From: alexisvisco Date: Wed, 10 Apr 2019 21:25:55 +0200 Subject: [PATCH 07/28] auto completion work in any cases. --- src/components/Container/NewContainer.tsx | 2 +- src/components/Form/AutocompleteInput.tsx | 30 ++++++++++++++++------- src/components/Form/index.ts | 3 ++- 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/src/components/Container/NewContainer.tsx b/src/components/Container/NewContainer.tsx index 57e9318..2746302 100644 --- a/src/components/Container/NewContainer.tsx +++ b/src/components/Container/NewContainer.tsx @@ -8,7 +8,7 @@ import AutocompleteInput from "../Form/AutocompleteInput"; const test = () => new Promise((resolve, reject) => setTimeout(resolve, 4000)) export default () => { - const { loading, error, handleChange, handleSubmit, dispatch, values } = useForm({ + const { loading, error, handleChange, handleSubmit, dispatch, values } = useForm({ name: "", image: "", size: "", diff --git a/src/components/Form/AutocompleteInput.tsx b/src/components/Form/AutocompleteInput.tsx index 6428d36..eecaca1 100644 --- a/src/components/Form/AutocompleteInput.tsx +++ b/src/components/Form/AutocompleteInput.tsx @@ -1,7 +1,7 @@ import { styled, theme } from "../../style" import { Input } from "./Input"; -import React, { ChangeEvent, FocusEventHandler, KeyboardEventHandler, useEffect, useState } from "react"; +import React, { ChangeEvent, useEffect, useState } from "react"; const AutocompleteInput = styled(Input)` ` @@ -10,8 +10,10 @@ const AutocompleteItems = styled.div` margin-top: 10px; border-radius: 0.25rem; position: absolute; + display: block; z-index: 99; border: 1px solid ${theme.color.grey}; + width: 100%; ` const AutocompleteItem = styled.div` @@ -35,11 +37,16 @@ export default ({ name, onChange, suggestions, tags = false }: IProps) => { const [completions, setCompletions] = useState([]) const [value, setValue] = useState("") const [selectedIndex, setSelectedIndex] = useState(-1) + const [id] = useState('_' + Math.random().toString(36).substr(2, 9)) useEffect(() => { - const blur = (_: any) => hide() - document.addEventListener("click", blur) - return () => document.removeEventListener("click", blur) + const focusOut = (_: any) => hide() + document.addEventListener("click", focusOut) + window.addEventListener("resize", focusOut) + return () => { + document.removeEventListener("click", focusOut) + window.removeEventListener("resize", focusOut) + } }) const change = (e: ChangeEvent) => { @@ -49,8 +56,9 @@ export default ({ name, onChange, suggestions, tags = false }: IProps) => { } const select = (val: string) => { - setValue(val); + setValue(val) hide() + onChange({target: {name, value: val}} as ChangeEvent) } const hide = () => { @@ -58,7 +66,6 @@ export default ({ name, onChange, suggestions, tags = false }: IProps) => { setCompletions([]) } - const key = (e: any) => { const down = () => setSelectedIndex(selectedIndex + 1 > completions.length - 1 ? 0 : selectedIndex + 1) const up = () => setSelectedIndex(selectedIndex + 1 > completions.length - 1 ? 0 : selectedIndex + 1) @@ -70,16 +77,21 @@ export default ({ name, onChange, suggestions, tags = false }: IProps) => { e.preventDefault() select(completions[selectedIndex]) } + } + const getSize = () => { + const d = document.getElementById(id) + if (d) return d.offsetWidth + "px" + else return '100%' } return ( -
- +
+ { completions.length > 0 ? - + {completions.map((val, index) => ( select(val)} key={index}>{val})) diff --git a/src/components/Form/index.ts b/src/components/Form/index.ts index 1bb93bd..a756ba6 100644 --- a/src/components/Form/index.ts +++ b/src/components/Form/index.ts @@ -1,7 +1,8 @@ import { Input, Select } from "./Input" +import AutocompleteInput from "./AutocompleteInput" import { Button, ButtonLink } from "./Button" export { default as Form } from "./Form" export { default as FormGroup } from "./FormGroup" export { default as FormSection } from "./FormSection" -export { Input, Select, Button, ButtonLink } +export { Input, Select, Button, ButtonLink, AutocompleteInput } From 8bbdc212cf2cdd3263c0272b5640e6b2cbc0238b Mon Sep 17 00:00:00 2001 From: alexisvisco Date: Thu, 11 Apr 2019 12:10:23 +0200 Subject: [PATCH 08/28] tags working --- src/components/Container/ListContainer.tsx | 2 +- src/components/Container/NewContainer.tsx | 2 +- src/components/Form/AutocompleteInput.tsx | 134 +++++++++++++++++---- 3 files changed, 114 insertions(+), 24 deletions(-) diff --git a/src/components/Container/ListContainer.tsx b/src/components/Container/ListContainer.tsx index 5dea309..b76c6dc 100644 --- a/src/components/Container/ListContainer.tsx +++ b/src/components/Container/ListContainer.tsx @@ -51,7 +51,7 @@ const columns = [ }, { title: "Tags", - key: "tags", + key: "hasTags", render: (tags: any) => ( <> {tags.map((tag: string, index: number) => ( diff --git a/src/components/Container/NewContainer.tsx b/src/components/Container/NewContainer.tsx index 2746302..c53a4e9 100644 --- a/src/components/Container/NewContainer.tsx +++ b/src/components/Container/NewContainer.tsx @@ -37,7 +37,7 @@ export default () => { - + diff --git a/src/components/Form/AutocompleteInput.tsx b/src/components/Form/AutocompleteInput.tsx index eecaca1..ce62c92 100644 --- a/src/components/Form/AutocompleteInput.tsx +++ b/src/components/Form/AutocompleteInput.tsx @@ -1,9 +1,64 @@ import { styled, theme } from "../../style" -import { Input } from "./Input"; - import React, { ChangeEvent, useEffect, useState } from "react"; -const AutocompleteInput = styled(Input)` +const Input = styled.input` + margin-bottom: 5px; + flex-grow:0; + flex-shrink:0; + border: none; + width: 285px; + outline: none; + box-shadow: none; + font-size: .9375rem; + font-weight: 400; + line-height: 1.5; + background-clip: padding-box; + &:focus { + outline: none; + box-shadow: none; + } + &:disabled, &[readonly] { + background: ${props => props.theme.color.grey}; + } +` + +const Tag = styled.span` + margin-bottom: 0.375rem; + border-radius: 3px; + flex-grow:0; + flex-shrink:0; + background: ${theme.color.greyDark}; + color: white; + display: inline-block; + text-align: center; + padding: 0.25rem; + margin-right: 0.5rem; +` + +const Autocomplete = styled.div` + display: flex; + flex-flow: row wrap; + width: 100%; + padding: 0.375rem 0.75rem 0; + font-size: .9375rem; + font-weight: 400; + line-height: 1.5; + color: ${props => props.theme.text.normal}; + background-color: #fff; + background-clip: padding-box; + border: 1px solid ${props => props.theme.color.grey}; + border-radius: 0.25rem; + transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; + + &:focus { + outline: none; + box-shadow: none; + border-color: ${props => props.theme.color.blue}; + } + + &:disabled, &[readonly] { + background: ${props => props.theme.color.grey}; + } ` const AutocompleteItems = styled.div` @@ -27,18 +82,22 @@ const AutocompleteItem = styled.div` interface IProps { suggestions: string[] - tags?: boolean, + hasTags?: boolean, onChange: (event: ChangeEvent) => void - name: string + name: string, + placeholder: string, } -export default ({ name, onChange, suggestions, tags = false }: IProps) => { +export default ({ name, onChange, suggestions, placeholder = "", hasTags = false }: IProps) => { const [completions, setCompletions] = useState([]) const [value, setValue] = useState("") - const [selectedIndex, setSelectedIndex] = useState(-1) + const [completionIndex, setCompletionIndex] = useState(-1) const [id] = useState('_' + Math.random().toString(36).substr(2, 9)) + const [tags, setTags] = useState>(new Set()) + const [tagsIndex, setTagsIndex] = useState(-1) + useEffect(() => { const focusOut = (_: any) => hide() document.addEventListener("click", focusOut) @@ -51,49 +110,80 @@ export default ({ name, onChange, suggestions, tags = false }: IProps) => { const change = (e: ChangeEvent) => { setValue(e.target.value) - setCompletions(suggestions.filter(x => e.target.value.length > 0 && x.startsWith(e.target.value))) + setCompletions(suggestions + .filter(x => e.target.value.length > 0 && x.startsWith(e.target.value)) + .slice(0, 5)) onChange(e) } const select = (val: string) => { - setValue(val) + if (hasTags && val.trim().length !== 0) { + addTags(val) + select("") + } else { + setValue(val) + } hide() - onChange({target: {name, value: val}} as ChangeEvent) + onChange({ target: { name, value: val } } as ChangeEvent) } const hide = () => { - setSelectedIndex(-1) + setCompletionIndex(-1) setCompletions([]) } + const addTags = (val: string) => setTags(new Set(Array.from(tags).concat([val]))) + const removeTags = (val: string) => setTags(new Set(Array.from(tags).filter(e => e !== val))) + const key = (e: any) => { - const down = () => setSelectedIndex(selectedIndex + 1 > completions.length - 1 ? 0 : selectedIndex + 1) - const up = () => setSelectedIndex(selectedIndex + 1 > completions.length - 1 ? 0 : selectedIndex + 1) + + const completionDown = () => setCompletionIndex(completionIndex + 1 > completions.length - 1 ? 0 : completionIndex + 1) + const completionUp = () => setCompletionIndex(completionIndex - 1 < 0 ? completions.length - 1 : completionIndex - 1) + + const tagsRight = () => setTagsIndex(tagsIndex + 1 > completions.length - 1 ? 0 : tagsIndex + 1) + const tagsLeft = () => setTagsIndex(tagsIndex - 1 < 0 ? completions.length - 1 : tagsIndex - 1) + + console.log(e.keyCode) if (e.keyCode === 27 || e.keyCode === 9) hide() - else if (e.keyCode === 40) down() - else if (e.keyCode === 38) up() - else if (e.keyCode === 13) { + else if (e.keyCode === 40) completionDown() + else if (e.keyCode === 38) completionUp() + else if (e.keyCode === 37) tagsLeft() + else if (e.keyCode === 39) tagsRight() + else if (e.keyCode === 13 && completionIndex !== -1) { e.preventDefault() - select(completions[selectedIndex]) + select(completions[completionIndex]) + } else if (e.keyCode === 13 && hasTags && value.trim().length > 0 && completionIndex === -1) { + e.preventDefault() + select(value) + select("") + } else if (e.keyCode === 8 && hasTags && value.trim().length === 0) { + if (tags.size > 0) { + removeTags(Array.from(tags)[tags.size - 1]) + } } } - const getSize = () => { + const sizeOfInput = () => { const d = document.getElementById(id) if (d) return d.offsetWidth + "px" else return '100%' } return ( -
- +
+ + {Array.from(tags).map((tag, index) => + ({tag})) + } + + { completions.length > 0 ? - + {completions.map((val, index) => - ( select(val)} key={index}>{val})) } From 9764f1788987a7b9d5d9e0c330cf6d6260f1e158 Mon Sep 17 00:00:00 2001 From: alexisvisco Date: Thu, 11 Apr 2019 17:43:16 +0200 Subject: [PATCH 09/28] tags work, missing focus --- src/components/Container/NewContainer.tsx | 5 +- .../{AutocompleteInput.tsx => TagsInput.tsx} | 124 ++++++++++++------ src/components/Form/index.ts | 2 +- 3 files changed, 91 insertions(+), 40 deletions(-) rename src/components/Form/{AutocompleteInput.tsx => TagsInput.tsx} (52%) diff --git a/src/components/Container/NewContainer.tsx b/src/components/Container/NewContainer.tsx index c53a4e9..938acc1 100644 --- a/src/components/Container/NewContainer.tsx +++ b/src/components/Container/NewContainer.tsx @@ -3,7 +3,7 @@ import { Header } from "../Layout" import { Button, Form, FormGroup, FormSection, Input, Select } from "../Form" import { Col, Container, Row } from "../Responsive" import { useForm } from "../../hooks" -import AutocompleteInput from "../Form/AutocompleteInput"; +import TagsInput from "../Form/TagsInput"; const test = () => new Promise((resolve, reject) => setTimeout(resolve, 4000)) @@ -37,7 +37,8 @@ export default () => { - + console.log(a)}/> diff --git a/src/components/Form/AutocompleteInput.tsx b/src/components/Form/TagsInput.tsx similarity index 52% rename from src/components/Form/AutocompleteInput.tsx rename to src/components/Form/TagsInput.tsx index ce62c92..b4bd06b 100644 --- a/src/components/Form/AutocompleteInput.tsx +++ b/src/components/Form/TagsInput.tsx @@ -22,7 +22,7 @@ const Input = styled.input` } ` -const Tag = styled.span` +const Tag = styled.p` margin-bottom: 0.375rem; border-radius: 3px; flex-grow:0; @@ -30,9 +30,11 @@ const Tag = styled.span` background: ${theme.color.greyDark}; color: white; display: inline-block; - text-align: center; - padding: 0.25rem; + max-width: 100%; + word-wrap: break-word; + padding: 0 0.5rem; margin-right: 0.5rem; + cursor: pointer; ` const Autocomplete = styled.div` @@ -82,24 +84,41 @@ const AutocompleteItem = styled.div` interface IProps { suggestions: string[] - hasTags?: boolean, - onChange: (event: ChangeEvent) => void + onChange: (event: string[]) => void name: string, placeholder: string, } -export default ({ name, onChange, suggestions, placeholder = "", hasTags = false }: IProps) => { +export default ({ name, onChange, suggestions, placeholder = ""}: IProps) => { const [completions, setCompletions] = useState([]) const [value, setValue] = useState("") const [completionIndex, setCompletionIndex] = useState(-1) const [id] = useState('_' + Math.random().toString(36).substr(2, 9)) + const [idInput] = useState('_' + Math.random().toString(36).substr(2, 9)) - const [tags, setTags] = useState>(new Set()) + const [tags, setTags] = useState>(new Set(["world", "is", 'fun', "and", "it is fun to do that"])) const [tagsIndex, setTagsIndex] = useState(-1) + const updateTags = (list: Set) => { + onChange(Array.from(list)) + setTags(list) + } + useEffect(() => { - const focusOut = (_: any) => hide() + const focusOut = (event: any) => { + hideCompletion() + if (event.type === "resize" || (!event.path[0].className.includes("tag") && + !event.path[0].className.includes("autocomplete-item"))) { + hideTagsIndex() + if (value.trim().length !== 0) { + addTags(value) + clean() + } + } else { + event.preventDefault() + } + } document.addEventListener("click", focusOut) window.addEventListener("resize", focusOut) return () => { @@ -108,58 +127,89 @@ export default ({ name, onChange, suggestions, placeholder = "", hasTags = false } }) + + const change = (e: ChangeEvent) => { + hideTagsIndex() setValue(e.target.value) setCompletions(suggestions .filter(x => e.target.value.length > 0 && x.startsWith(e.target.value)) .slice(0, 5)) - onChange(e) + } + + const clean = () => { + setValue("") } const select = (val: string) => { - if (hasTags && val.trim().length !== 0) { + if (val.trim().length !== 0) { + console.log(val) addTags(val) - select("") - } else { - setValue(val) + clean() } - hide() - onChange({ target: { name, value: val } } as ChangeEvent) + hideCompletion() } - const hide = () => { + const hideCompletion = () => { setCompletionIndex(-1) setCompletions([]) } + + const hideTagsIndex = () => setTagsIndex(-1) + + const clickTag = (e: any, tagIndex: number) => { + e.preventDefault() + setTagsIndex(tagIndex) + if (value.trim().length !== 0) { + addTags(value) + clean() + } + focusInput() + } + + const clickCompletion = (e: any, completion: string) => { + e.preventDefault() + addTags(completion) + clean() + hideCompletion() + } + + const focusInput = () => { + const input = document.getElementById(idInput) + console.log(input) + if (input) return input.focus() + } - const addTags = (val: string) => setTags(new Set(Array.from(tags).concat([val]))) - const removeTags = (val: string) => setTags(new Set(Array.from(tags).filter(e => e !== val))) + const addTags = (val: string) => updateTags(new Set(Array.from(tags).concat([val]))) + const removeTags = (val: string) => updateTags(new Set(Array.from(tags).filter(e => e !== val))) const key = (e: any) => { const completionDown = () => setCompletionIndex(completionIndex + 1 > completions.length - 1 ? 0 : completionIndex + 1) - const completionUp = () => setCompletionIndex(completionIndex - 1 < 0 ? completions.length - 1 : completionIndex - 1) + const completionUp = () => setCompletionIndex(completionIndex - 1 < 0 ? completions.length - 1 : completionIndex - 1) - const tagsRight = () => setTagsIndex(tagsIndex + 1 > completions.length - 1 ? 0 : tagsIndex + 1) - const tagsLeft = () => setTagsIndex(tagsIndex - 1 < 0 ? completions.length - 1 : tagsIndex - 1) + const tagsRight = () => setTagsIndex(tagsIndex + 1 > tags.size - 1 ? 0 : tagsIndex + 1) + const tagsLeft = () => setTagsIndex(tagsIndex - 1 < 0 ? tags.size - 1 : tagsIndex - 1) - console.log(e.keyCode) + if (e.keyCode === 13) e.preventDefault() - if (e.keyCode === 27 || e.keyCode === 9) hide() + if (e.keyCode === 27 || e.keyCode === 9) hideCompletion() else if (e.keyCode === 40) completionDown() else if (e.keyCode === 38) completionUp() - else if (e.keyCode === 37) tagsLeft() - else if (e.keyCode === 39) tagsRight() - else if (e.keyCode === 13 && completionIndex !== -1) { - e.preventDefault() - select(completions[completionIndex]) - } else if (e.keyCode === 13 && hasTags && value.trim().length > 0 && completionIndex === -1) { - e.preventDefault() + else if (e.keyCode === 37 && value.trim().length === 0) tagsLeft() + else if (e.keyCode === 39 && value.trim().length === 0) tagsRight() + else if (e.keyCode === 13 && completionIndex !== -1) select(completions[completionIndex]) + else if (e.keyCode === 13 && value.trim().length > 0 && completionIndex === -1) { select(value) - select("") - } else if (e.keyCode === 8 && hasTags && value.trim().length === 0) { - if (tags.size > 0) { + clean() + } else if (e.keyCode === 8 && value.trim().length === 0) { + if (tags.size > 0 && tagsIndex === -1) { removeTags(Array.from(tags)[tags.size - 1]) + hideTagsIndex() + } else if (tags.size > 0) { + removeTags(Array.from(tags)[tagsIndex]) + if (tagsIndex === tags.size - 1) + hideTagsIndex() } } } @@ -174,17 +224,17 @@ export default ({ name, onChange, suggestions, placeholder = "", hasTags = false
{Array.from(tags).map((tag, index) => - ({tag})) + ( clickTag(e, index)} style={index !== tagsIndex ? {} : { background: theme.color.dark }} key={index}>{tag})) } - + { completions.length > 0 ? {completions.map((val, index) => - ( select(val)} key={index}>{val})) + ( clickCompletion(e, val)} key={index}>{val})) } : <> diff --git a/src/components/Form/index.ts b/src/components/Form/index.ts index a756ba6..36cac5c 100644 --- a/src/components/Form/index.ts +++ b/src/components/Form/index.ts @@ -1,5 +1,5 @@ import { Input, Select } from "./Input" -import AutocompleteInput from "./AutocompleteInput" +import AutocompleteInput from "./TagsInput" import { Button, ButtonLink } from "./Button" export { default as Form } from "./Form" From eb9bcac560aa0e30348c9448bc992e6aaf4efd91 Mon Sep 17 00:00:00 2001 From: alexisvisco Date: Thu, 11 Apr 2019 17:51:12 +0200 Subject: [PATCH 10/28] fix input width --- src/components/Form/TagsInput.tsx | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/components/Form/TagsInput.tsx b/src/components/Form/TagsInput.tsx index b4bd06b..fa1cc03 100644 --- a/src/components/Form/TagsInput.tsx +++ b/src/components/Form/TagsInput.tsx @@ -2,11 +2,11 @@ import { styled, theme } from "../../style" import React, { ChangeEvent, useEffect, useState } from "react"; const Input = styled.input` + width: auto; margin-bottom: 5px; - flex-grow:0; - flex-shrink:0; + flex-grow: 1; + flex-shrink: 0; border: none; - width: 285px; outline: none; box-shadow: none; font-size: .9375rem; @@ -89,7 +89,7 @@ interface IProps { placeholder: string, } -export default ({ name, onChange, suggestions, placeholder = ""}: IProps) => { +export default ({ name, onChange, suggestions, placeholder = "" }: IProps) => { const [completions, setCompletions] = useState([]) const [value, setValue] = useState("") @@ -128,7 +128,6 @@ export default ({ name, onChange, suggestions, placeholder = ""}: IProps) => { }) - const change = (e: ChangeEvent) => { hideTagsIndex() setValue(e.target.value) @@ -154,7 +153,7 @@ export default ({ name, onChange, suggestions, placeholder = ""}: IProps) => { setCompletionIndex(-1) setCompletions([]) } - + const hideTagsIndex = () => setTagsIndex(-1) const clickTag = (e: any, tagIndex: number) => { @@ -224,16 +223,19 @@ export default ({ name, onChange, suggestions, placeholder = ""}: IProps) => {
{Array.from(tags).map((tag, index) => - ( clickTag(e, index)} style={index !== tagsIndex ? {} : { background: theme.color.dark }} key={index}>{tag})) + ( clickTag(e, index)} + style={index !== tagsIndex ? {} : { background: theme.color.dark }} key={index}>{tag})) } - + { completions.length > 0 ? {completions.map((val, index) => - ( clickCompletion(e, val)} key={index}>{val})) } From 4c04cb8b0042e0ff599967214734d583ff8f05f1 Mon Sep 17 00:00:00 2001 From: alexisvisco Date: Thu, 11 Apr 2019 17:59:51 +0200 Subject: [PATCH 11/28] fix esc with tags, need to add tabs --- src/components/Form/TagsInput.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/components/Form/TagsInput.tsx b/src/components/Form/TagsInput.tsx index fa1cc03..d04adec 100644 --- a/src/components/Form/TagsInput.tsx +++ b/src/components/Form/TagsInput.tsx @@ -192,11 +192,14 @@ export default ({ name, onChange, suggestions, placeholder = "" }: IProps) => { if (e.keyCode === 13) e.preventDefault() - if (e.keyCode === 27 || e.keyCode === 9) hideCompletion() + if (e.keyCode === 27 || e.keyCode === 9) { + hideCompletion() + if (e.keyCode !== 9) hideTagsIndex() + } else if (e.keyCode === 40) completionDown() else if (e.keyCode === 38) completionUp() else if (e.keyCode === 37 && value.trim().length === 0) tagsLeft() - else if (e.keyCode === 39 && value.trim().length === 0) tagsRight() + else if ((e.keyCode === 39) && value.trim().length === 0) tagsRight() else if (e.keyCode === 13 && completionIndex !== -1) select(completions[completionIndex]) else if (e.keyCode === 13 && value.trim().length > 0 && completionIndex === -1) { select(value) From f7d1aaefbbf14fdc88c5f9d7a5243f976bac0d10 Mon Sep 17 00:00:00 2001 From: alexisvisco Date: Thu, 11 Apr 2019 21:00:49 +0200 Subject: [PATCH 12/28] add focus to tags component --- src/components/Form/TagsInput.tsx | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/components/Form/TagsInput.tsx b/src/components/Form/TagsInput.tsx index d04adec..eb5c056 100644 --- a/src/components/Form/TagsInput.tsx +++ b/src/components/Form/TagsInput.tsx @@ -52,12 +52,6 @@ const Autocomplete = styled.div` border-radius: 0.25rem; transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; - &:focus { - outline: none; - box-shadow: none; - border-color: ${props => props.theme.color.blue}; - } - &:disabled, &[readonly] { background: ${props => props.theme.color.grey}; } @@ -94,6 +88,7 @@ export default ({ name, onChange, suggestions, placeholder = "" }: IProps) => { const [completions, setCompletions] = useState([]) const [value, setValue] = useState("") const [completionIndex, setCompletionIndex] = useState(-1) + const [isFocused, setFocused] = useState(false) const [id] = useState('_' + Math.random().toString(36).substr(2, 9)) const [idInput] = useState('_' + Math.random().toString(36).substr(2, 9)) @@ -108,8 +103,11 @@ export default ({ name, onChange, suggestions, placeholder = "" }: IProps) => { useEffect(() => { const focusOut = (event: any) => { hideCompletion() - if (event.type === "resize" || (!event.path[0].className.includes("tag") && + if (event.type === "resize" || + (!event.path[0].className.includes("tag") && + !event.path[0].id.includes(idInput) && !event.path[0].className.includes("autocomplete-item"))) { + setFocused(false) hideTagsIndex() if (value.trim().length !== 0) { addTags(value) @@ -142,7 +140,6 @@ export default ({ name, onChange, suggestions, placeholder = "" }: IProps) => { const select = (val: string) => { if (val.trim().length !== 0) { - console.log(val) addTags(val) clean() } @@ -175,7 +172,6 @@ export default ({ name, onChange, suggestions, placeholder = "" }: IProps) => { const focusInput = () => { const input = document.getElementById(idInput) - console.log(input) if (input) return input.focus() } @@ -224,7 +220,7 @@ export default ({ name, onChange, suggestions, placeholder = "" }: IProps) => { return (
- + setFocused(true)}> {Array.from(tags).map((tag, index) => ( clickTag(e, index)} style={index !== tagsIndex ? {} : { background: theme.color.dark }} key={index}>{tag})) From 6761e740dceed060bea40d2ce9824064d0120e10 Mon Sep 17 00:00:00 2001 From: alexisvisco Date: Thu, 11 Apr 2019 21:06:21 +0200 Subject: [PATCH 13/28] fix focus, add tabulation on next tags --- src/components/Container/NewContainer.tsx | 2 +- src/components/Form/TagsInput.tsx | 12 ++++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/components/Container/NewContainer.tsx b/src/components/Container/NewContainer.tsx index 938acc1..d0ab84f 100644 --- a/src/components/Container/NewContainer.tsx +++ b/src/components/Container/NewContainer.tsx @@ -37,7 +37,7 @@ export default () => { - console.log(a)}/> diff --git a/src/components/Form/TagsInput.tsx b/src/components/Form/TagsInput.tsx index eb5c056..567cd24 100644 --- a/src/components/Form/TagsInput.tsx +++ b/src/components/Form/TagsInput.tsx @@ -81,9 +81,10 @@ interface IProps { onChange: (event: string[]) => void name: string, placeholder: string, + defaultTags: string[] } -export default ({ name, onChange, suggestions, placeholder = "" }: IProps) => { +export default ({ name, onChange, suggestions, placeholder = "", defaultTags = [] }: IProps) => { const [completions, setCompletions] = useState([]) const [value, setValue] = useState("") @@ -92,7 +93,7 @@ export default ({ name, onChange, suggestions, placeholder = "" }: IProps) => { const [id] = useState('_' + Math.random().toString(36).substr(2, 9)) const [idInput] = useState('_' + Math.random().toString(36).substr(2, 9)) - const [tags, setTags] = useState>(new Set(["world", "is", 'fun', "and", "it is fun to do that"])) + const [tags, setTags] = useState>(new Set(defaultTags)) const [tagsIndex, setTagsIndex] = useState(-1) const updateTags = (list: Set) => { @@ -168,6 +169,7 @@ export default ({ name, onChange, suggestions, placeholder = "" }: IProps) => { addTags(completion) clean() hideCompletion() + focusInput() } const focusInput = () => { @@ -191,6 +193,12 @@ export default ({ name, onChange, suggestions, placeholder = "" }: IProps) => { if (e.keyCode === 27 || e.keyCode === 9) { hideCompletion() if (e.keyCode !== 9) hideTagsIndex() + if (e.keyCode === 9 && tagsIndex !== -1) { + e.preventDefault() + tagsRight() + } else { + setFocused(false) + } } else if (e.keyCode === 40) completionDown() else if (e.keyCode === 38) completionUp() From 8cdb19dd5417a3d7613c1393397733b3e0dd0a8b Mon Sep 17 00:00:00 2001 From: alexisvisco Date: Thu, 11 Apr 2019 21:45:39 +0200 Subject: [PATCH 14/28] add render function and optional parameters --- src/components/Container/NewContainer.tsx | 2 +- src/components/Form/TagsInput.tsx | 15 ++++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/components/Container/NewContainer.tsx b/src/components/Container/NewContainer.tsx index d0ab84f..938acc1 100644 --- a/src/components/Container/NewContainer.tsx +++ b/src/components/Container/NewContainer.tsx @@ -37,7 +37,7 @@ export default () => { - console.log(a)}/> diff --git a/src/components/Form/TagsInput.tsx b/src/components/Form/TagsInput.tsx index 567cd24..8bf71ec 100644 --- a/src/components/Form/TagsInput.tsx +++ b/src/components/Form/TagsInput.tsx @@ -1,5 +1,5 @@ import { styled, theme } from "../../style" -import React, { ChangeEvent, useEffect, useState } from "react"; +import React, { ChangeEvent, ReactNode, useEffect, useState } from "react"; const Input = styled.input` width: auto; @@ -80,11 +80,16 @@ interface IProps { suggestions: string[] onChange: (event: string[]) => void name: string, - placeholder: string, - defaultTags: string[] + placeholder?: string, + defaultTags?: string[], + suggestionRender?: (suggest: string) => ReactNode } -export default ({ name, onChange, suggestions, placeholder = "", defaultTags = [] }: IProps) => { +const defaultSuggestionRender = (suggest: string) => { + return <>{suggest} +} + +export default ({ name, onChange, suggestions, placeholder = "", defaultTags = [], suggestionRender = defaultSuggestionRender }: IProps) => { const [completions, setCompletions] = useState([]) const [value, setValue] = useState("") @@ -243,7 +248,7 @@ export default ({ name, onChange, suggestions, placeholder = "", defaultTags = [ {completions.map((val, index) => ( clickCompletion(e, val)} key={index}>{val})) + onClick={(e) => clickCompletion(e, val)} key={index}>{suggestionRender(val)})) } : <> From b22eaa180cf5bf1856dd4ccc5a934d34dd2a0159 Mon Sep 17 00:00:00 2001 From: alexisvisco Date: Fri, 12 Apr 2019 09:57:28 +0200 Subject: [PATCH 15/28] tag input is okay --- src/components/Container/NewContainer.tsx | 6 +++--- src/components/Form/TagsInput.tsx | 21 +++++++++++++-------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/src/components/Container/NewContainer.tsx b/src/components/Container/NewContainer.tsx index 938acc1..fd6b667 100644 --- a/src/components/Container/NewContainer.tsx +++ b/src/components/Container/NewContainer.tsx @@ -8,7 +8,7 @@ import TagsInput from "../Form/TagsInput"; const test = () => new Promise((resolve, reject) => setTimeout(resolve, 4000)) export default () => { - const { loading, error, handleChange, handleSubmit, dispatch, values } = useForm({ + const { loading, error, handleChange, handleSubmit, dispatch, values } = useForm({ name: "", image: "", size: "", @@ -37,8 +37,8 @@ export default () => { - console.log(a)}/> + console.log(a)}/> diff --git a/src/components/Form/TagsInput.tsx b/src/components/Form/TagsInput.tsx index 8bf71ec..d7f6677 100644 --- a/src/components/Form/TagsInput.tsx +++ b/src/components/Form/TagsInput.tsx @@ -109,10 +109,13 @@ export default ({ name, onChange, suggestions, placeholder = "", defaultTags = [ useEffect(() => { const focusOut = (event: any) => { hideCompletion() + if (event.type === "click" && event.path[0].id.includes(idInput)) { + hideTagsIndex() + } if (event.type === "resize" || (!event.path[0].className.includes("tag") && - !event.path[0].id.includes(idInput) && - !event.path[0].className.includes("autocomplete-item"))) { + !event.path[0].id.includes(idInput) && + !event.path[0].className.includes("autocomplete-item"))) { setFocused(false) hideTagsIndex() if (value.trim().length !== 0) { @@ -182,7 +185,7 @@ export default ({ name, onChange, suggestions, placeholder = "", defaultTags = [ if (input) return input.focus() } - const addTags = (val: string) => updateTags(new Set(Array.from(tags).concat([val]))) + const addTags = (val: string) => updateTags(new Set(Array.from(tags).concat([val.trim()]))) const removeTags = (val: string) => updateTags(new Set(Array.from(tags).filter(e => e !== val))) const key = (e: any) => { @@ -201,11 +204,11 @@ export default ({ name, onChange, suggestions, placeholder = "", defaultTags = [ if (e.keyCode === 9 && tagsIndex !== -1) { e.preventDefault() tagsRight() - } else { + } else if (e.keyCode === 9) { + select(value) setFocused(false) } - } - else if (e.keyCode === 40) completionDown() + } else if (e.keyCode === 40) completionDown() else if (e.keyCode === 38) completionUp() else if (e.keyCode === 37 && value.trim().length === 0) tagsLeft() else if ((e.keyCode === 39) && value.trim().length === 0) tagsRight() @@ -233,7 +236,8 @@ export default ({ name, onChange, suggestions, placeholder = "", defaultTags = [ return (
- setFocused(true)}> + setFocused(true)}> {Array.from(tags).map((tag, index) => ( clickTag(e, index)} style={index !== tagsIndex ? {} : { background: theme.color.dark }} key={index}>{tag})) @@ -248,7 +252,8 @@ export default ({ name, onChange, suggestions, placeholder = "", defaultTags = [ {completions.map((val, index) => ( clickCompletion(e, val)} key={index}>{suggestionRender(val)})) + onClick={(e) => clickCompletion(e, val)} + key={index}>{suggestionRender(val)})) } : <> From 9d3c2eb3cff5a8d185a4416cd328420a9a43827d Mon Sep 17 00:00:00 2001 From: alexisvisco Date: Fri, 12 Apr 2019 10:24:13 +0200 Subject: [PATCH 16/28] add auto completion input --- src/components/Container/NewContainer.tsx | 12 +- src/components/Form/AutocompleteInput.tsx | 143 ++++++++++++++++++++++ src/components/Form/index.ts | 5 +- 3 files changed, 154 insertions(+), 6 deletions(-) create mode 100644 src/components/Form/AutocompleteInput.tsx diff --git a/src/components/Container/NewContainer.tsx b/src/components/Container/NewContainer.tsx index fd6b667..b8be8a0 100644 --- a/src/components/Container/NewContainer.tsx +++ b/src/components/Container/NewContainer.tsx @@ -1,9 +1,8 @@ import React from "react" import { Header } from "../Layout" -import { Button, Form, FormGroup, FormSection, Input, Select } from "../Form" +import { AutocompleteInput, TagsInput, Button, Form, FormGroup, FormSection, Input, Select } from "../Form" import { Col, Container, Row } from "../Responsive" import { useForm } from "../../hooks" -import TagsInput from "../Form/TagsInput"; const test = () => new Promise((resolve, reject) => setTimeout(resolve, 4000)) @@ -31,8 +30,13 @@ export default () => { - + console.log("image:", a.target.value)} /> ) => void + name: string, + placeholder?: string, + defaultTags?: string[], + suggestionRender?: (suggest: string) => ReactNode +} + +const defaultSuggestionRender = (suggest: string) => { + return <>{suggest} +} + +export default ({ name, onChange, suggestions, placeholder = "", suggestionRender = defaultSuggestionRender }: IProps) => { + + const [completions, setCompletions] = useState([]) + const [value, setValue] = useState("") + const [completionIndex, setCompletionIndex] = useState(-1) + + const [id] = useState('_' + Math.random().toString(36).substr(2, 9)) + const [idInput] = useState('_' + Math.random().toString(36).substr(2, 9)) + + useEffect(() => { + const focusOut = (event: any) => { + hideCompletion() + event.preventDefault() + } + document.addEventListener("click", focusOut) + window.addEventListener("resize", focusOut) + return () => { + document.removeEventListener("click", focusOut) + window.removeEventListener("resize", focusOut) + } + }) + + + const change = (e: ChangeEvent) => { + setValue(e.target.value) + setCompletions(suggestions + .filter(x => e.target.value.length > 0 && x.startsWith(e.target.value)) + .slice(0, 5)) + onChange(e) + } + + const clean = () => { + setValue("") + } + + const select = (val: string) => { + setValue(val) + onChange({target: { value: val, name}} as any) + hideCompletion() + } + + const hideCompletion = () => { + setCompletionIndex(-1) + setCompletions([]) + } + + const clickCompletion = (e: any, completion: string) => { + e.preventDefault() + select(completion) + clean() + hideCompletion() + focusInput() + } + + const focusInput = () => { + const input = document.getElementById(idInput) + if (input) return input.focus() + } + + + const key = (e: any) => { + + const completionDown = () => setCompletionIndex(completionIndex + 1 > completions.length - 1 ? 0 : completionIndex + 1) + const completionUp = () => setCompletionIndex(completionIndex - 1 < 0 ? completions.length - 1 : completionIndex - 1) + + if (e.keyCode === 13) e.preventDefault() + + if (e.keyCode === 27 || e.keyCode === 9) { + hideCompletion() + } else if (e.keyCode === 40) completionDown() + else if (e.keyCode === 38) completionUp() + else if (e.keyCode === 13 && completionIndex !== -1) select(completions[completionIndex]) + else if (e.keyCode === 13 && value.trim().length > 0 && completionIndex === -1) { + select(value) + clean() + } + } + + const sizeOfInput = () => { + const d = document.getElementById(id) + if (d) return d.offsetWidth + "px" + else return '100%' + } + + return ( +
+ + { + completions.length > 0 + ? + + {completions.map((val, index) => + ( clickCompletion(e, val)} + key={index}>{suggestionRender(val)})) + } + + : <> + } +
+ ) +} + diff --git a/src/components/Form/index.ts b/src/components/Form/index.ts index 36cac5c..7c1bd15 100644 --- a/src/components/Form/index.ts +++ b/src/components/Form/index.ts @@ -1,8 +1,9 @@ import { Input, Select } from "./Input" -import AutocompleteInput from "./TagsInput" +import TagsInput from "./TagsInput" +import AutocompleteInput from "./AutocompleteInput" import { Button, ButtonLink } from "./Button" export { default as Form } from "./Form" export { default as FormGroup } from "./FormGroup" export { default as FormSection } from "./FormSection" -export { Input, Select, Button, ButtonLink, AutocompleteInput } +export { Input, Select, Button, ButtonLink, AutocompleteInput, TagsInput} From 08d992c5c1eb258ee40f472924528e4b8bc32066 Mon Sep 17 00:00:00 2001 From: alexisvisco Date: Fri, 12 Apr 2019 10:26:21 +0200 Subject: [PATCH 17/28] add to NewContainer debug --- src/components/Container/NewContainer.tsx | 2 +- src/components/Form/AutocompleteInput.tsx | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/components/Container/NewContainer.tsx b/src/components/Container/NewContainer.tsx index b8be8a0..15668f1 100644 --- a/src/components/Container/NewContainer.tsx +++ b/src/components/Container/NewContainer.tsx @@ -42,7 +42,7 @@ export default () => { console.log(a)}/> + suggestions={["hello", "world", "hello world", "hai!"]} onChange={(a) => console.log("tags:", a)}/> diff --git a/src/components/Form/AutocompleteInput.tsx b/src/components/Form/AutocompleteInput.tsx index 96756eb..eb6ddc5 100644 --- a/src/components/Form/AutocompleteInput.tsx +++ b/src/components/Form/AutocompleteInput.tsx @@ -43,7 +43,6 @@ export default ({ name, onChange, suggestions, placeholder = "", suggestionRende const [completionIndex, setCompletionIndex] = useState(-1) const [id] = useState('_' + Math.random().toString(36).substr(2, 9)) - const [idInput] = useState('_' + Math.random().toString(36).substr(2, 9)) useEffect(() => { const focusOut = (event: any) => { @@ -91,7 +90,7 @@ export default ({ name, onChange, suggestions, placeholder = "", suggestionRende } const focusInput = () => { - const input = document.getElementById(idInput) + const input = document.getElementById(id) if (input) return input.focus() } From 6d868d93a98b1a903fa88ca51953ee34972a4462 Mon Sep 17 00:00:00 2001 From: alexisvisco Date: Fri, 12 Apr 2019 10:31:58 +0200 Subject: [PATCH 18/28] add header to ListImage --- src/components/Image/ListImage.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/components/Image/ListImage.tsx b/src/components/Image/ListImage.tsx index 8bcea9c..1f264fb 100644 --- a/src/components/Image/ListImage.tsx +++ b/src/components/Image/ListImage.tsx @@ -1,9 +1,15 @@ import React from "react" +import { ButtonLink } from "../Form"; +import { Header } from "../Layout"; export default () => { return ( <> - +
+ + Create + +
) } From 453403117960a44cbb9b9d7b87020bee3c28c049 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Caumette?= Date: Sat, 13 Apr 2019 22:01:30 +0200 Subject: [PATCH 19/28] start to fix --- src/components/Container/ListContainer.tsx | 6 ++-- src/components/Container/NewContainer.tsx | 6 ++-- src/components/Form/AutocompleteInput.tsx | 4 +-- .../Form/{TagsInput.tsx => TagInput.tsx} | 36 +++++++++---------- src/components/Form/index.ts | 6 ++-- 5 files changed, 28 insertions(+), 30 deletions(-) rename src/components/Form/{TagsInput.tsx => TagInput.tsx} (89%) diff --git a/src/components/Container/ListContainer.tsx b/src/components/Container/ListContainer.tsx index b76c6dc..701200d 100644 --- a/src/components/Container/ListContainer.tsx +++ b/src/components/Container/ListContainer.tsx @@ -51,11 +51,11 @@ const columns = [ }, { title: "Tags", - key: "hasTags", + key: "tags", render: (tags: any) => ( <> - {tags.map((tag: string, index: number) => ( - {tag} + {tags.map((tag: any, i: number) => ( + {tag} ))} ), diff --git a/src/components/Container/NewContainer.tsx b/src/components/Container/NewContainer.tsx index 15668f1..535a760 100644 --- a/src/components/Container/NewContainer.tsx +++ b/src/components/Container/NewContainer.tsx @@ -1,6 +1,6 @@ import React from "react" import { Header } from "../Layout" -import { AutocompleteInput, TagsInput, Button, Form, FormGroup, FormSection, Input, Select } from "../Form" +import { AutocompleteInput, TagInput, Button, Form, FormGroup, FormSection, Input, Select } from "../Form" import { Col, Container, Row } from "../Responsive" import { useForm } from "../../hooks" @@ -41,8 +41,8 @@ export default () => { - console.log("tags:", a)}/> + console.log("tags:", a)}/> diff --git a/src/components/Form/AutocompleteInput.tsx b/src/components/Form/AutocompleteInput.tsx index eb6ddc5..0a480af 100644 --- a/src/components/Form/AutocompleteInput.tsx +++ b/src/components/Form/AutocompleteInput.tsx @@ -1,6 +1,6 @@ import { styled, theme } from "../../style" -import React, { ChangeEvent, FormEvent, ReactNode, useEffect, useState } from "react"; -import { Input } from "./index"; +import React, { ChangeEvent, ReactNode, useEffect, useState } from "react" +import { Input } from "./index" const AutocompleteInput = styled(Input)`` diff --git a/src/components/Form/TagsInput.tsx b/src/components/Form/TagInput.tsx similarity index 89% rename from src/components/Form/TagsInput.tsx rename to src/components/Form/TagInput.tsx index d7f6677..ba6c67c 100644 --- a/src/components/Form/TagsInput.tsx +++ b/src/components/Form/TagInput.tsx @@ -1,5 +1,5 @@ import { styled, theme } from "../../style" -import React, { ChangeEvent, ReactNode, useEffect, useState } from "react"; +import React, { ChangeEvent, ReactNode, useEffect, useState } from "react" const Input = styled.input` width: auto; @@ -57,7 +57,7 @@ const Autocomplete = styled.div` } ` -const AutocompleteItems = styled.div` +const Suggestions = styled.div` margin-top: 10px; border-radius: 0.25rem; position: absolute; @@ -71,8 +71,9 @@ const AutocompleteItem = styled.div` cursor: pointer; background: white; padding: 0.3rem 0.8rem 0.3rem 0.8rem; + :hover{ - background: ${theme.color.grey}; + background: ${theme.color.greyLight}; } ` @@ -95,8 +96,8 @@ export default ({ name, onChange, suggestions, placeholder = "", defaultTags = [ const [value, setValue] = useState("") const [completionIndex, setCompletionIndex] = useState(-1) const [isFocused, setFocused] = useState(false) - const [id] = useState('_' + Math.random().toString(36).substr(2, 9)) - const [idInput] = useState('_' + Math.random().toString(36).substr(2, 9)) + const [id] = useState("_" + Math.random().toString(36).substr(2, 9)) + const [idInput] = useState("_" + Math.random().toString(36).substr(2, 9)) const [tags, setTags] = useState>(new Set(defaultTags)) const [tagsIndex, setTagsIndex] = useState(-1) @@ -231,7 +232,7 @@ export default ({ name, onChange, suggestions, placeholder = "", defaultTags = [ const sizeOfInput = () => { const d = document.getElementById(id) if (d) return d.offsetWidth + "px" - else return '100%' + else return "100%" } return ( @@ -245,19 +246,16 @@ export default ({ name, onChange, suggestions, placeholder = "", defaultTags = [
- { - completions.length > 0 - ? - - {completions.map((val, index) => - ( clickCompletion(e, val)} - key={index}>{suggestionRender(val)})) - } - - : <> - } + {completions.length > 0 && ( + + {completions.map((val, index) => + ( clickCompletion(e, val)} + key={index}>{suggestionRender(val)})) + } + + )}
) } diff --git a/src/components/Form/index.ts b/src/components/Form/index.ts index 7c1bd15..ba0b0e8 100644 --- a/src/components/Form/index.ts +++ b/src/components/Form/index.ts @@ -1,9 +1,9 @@ import { Input, Select } from "./Input" -import TagsInput from "./TagsInput" -import AutocompleteInput from "./AutocompleteInput" import { Button, ButtonLink } from "./Button" export { default as Form } from "./Form" +export { default as TagInput } from "./TagInput" +export { default as AutocompleteInput } from "./AutocompleteInput" export { default as FormGroup } from "./FormGroup" export { default as FormSection } from "./FormSection" -export { Input, Select, Button, ButtonLink, AutocompleteInput, TagsInput} +export { Input, Select, Button, ButtonLink } From 253d5c17726eb64a66c062b4dc3fd2a72573f1d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Caumette?= Date: Sun, 14 Apr 2019 00:02:40 +0200 Subject: [PATCH 20/28] rewrite with reducer --- src/components/Form/TagInput.tsx | 287 +++++++++++++------------------ 1 file changed, 120 insertions(+), 167 deletions(-) diff --git a/src/components/Form/TagInput.tsx b/src/components/Form/TagInput.tsx index ba6c67c..8256620 100644 --- a/src/components/Form/TagInput.tsx +++ b/src/components/Form/TagInput.tsx @@ -1,5 +1,5 @@ import { styled, theme } from "../../style" -import React, { ChangeEvent, ReactNode, useEffect, useState } from "react" +import React, { KeyboardEvent, ReactNode, useReducer, useState } from "react" const Input = styled.input` width: auto; @@ -13,10 +13,7 @@ const Input = styled.input` font-weight: 400; line-height: 1.5; background-clip: padding-box; - &:focus { - outline: none; - box-shadow: none; - } + &:disabled, &[readonly] { background: ${props => props.theme.color.grey}; } @@ -57,7 +54,7 @@ const Autocomplete = styled.div` } ` -const Suggestions = styled.div` +const SuggestionList = styled.div` margin-top: 10px; border-radius: 0.25rem; position: absolute; @@ -67,7 +64,7 @@ const Suggestions = styled.div` width: 100%; ` -const AutocompleteItem = styled.div` +const Suggestion = styled.div` cursor: pointer; background: white; padding: 0.3rem 0.8rem 0.3rem 0.8rem; @@ -80,183 +77,139 @@ const AutocompleteItem = styled.div` interface IProps { suggestions: string[] onChange: (event: string[]) => void - name: string, - placeholder?: string, - defaultTags?: string[], + name: string + placeholder?: string + defaultTags?: string[] suggestionRender?: (suggest: string) => ReactNode } -const defaultSuggestionRender = (suggest: string) => { - return <>{suggest} +interface IState { + value: string + tags: string[] + suggestions: string[] + currentSuggestions: string[] + suggestionOffset: number + suggestionIndex: number + tagIndex: number } -export default ({ name, onChange, suggestions, placeholder = "", defaultTags = [], suggestionRender = defaultSuggestionRender }: IProps) => { - - const [completions, setCompletions] = useState([]) - const [value, setValue] = useState("") - const [completionIndex, setCompletionIndex] = useState(-1) - const [isFocused, setFocused] = useState(false) - const [id] = useState("_" + Math.random().toString(36).substr(2, 9)) - const [idInput] = useState("_" + Math.random().toString(36).substr(2, 9)) - - const [tags, setTags] = useState>(new Set(defaultTags)) - const [tagsIndex, setTagsIndex] = useState(-1) - - const updateTags = (list: Set) => { - onChange(Array.from(list)) - setTags(list) - } - - useEffect(() => { - const focusOut = (event: any) => { - hideCompletion() - if (event.type === "click" && event.path[0].id.includes(idInput)) { - hideTagsIndex() +type Action = + { action: "SET_VALUE", value: string } | + { action: "ADD_TAG", value: string } | + { action: "DELETE_TAG", value: string } | + { action: "SET_TAG_INDEX", value: number } | + { action: "SET_SUGGESTION_INDEX", value: number } + +const reducer = (state: IState, action: Action) => { + switch (action.action) { + case "SET_VALUE": + return { + ...state, + value: action.value, + currentSuggestions: action.value.trim() ? + state.suggestions.filter(s => s.startsWith(action.value)) : [], + tagIndex: -1, + suggestionIndex: -1, } - if (event.type === "resize" || - (!event.path[0].className.includes("tag") && - !event.path[0].id.includes(idInput) && - !event.path[0].className.includes("autocomplete-item"))) { - setFocused(false) - hideTagsIndex() - if (value.trim().length !== 0) { - addTags(value) - clean() - } - } else { - event.preventDefault() + case "ADD_TAG": + return { + ...state, + value: "", + tags: state.tags.includes(action.value) || !action.value.trim() ? + state.tags : [...state.tags, action.value], + tagIndex: -1, + suggestionIndex: -1, + } + case "DELETE_TAG": + return { + ...state, + tags: state.tags.filter(tag => tag !== action.value), + tagIndex: -1, + suggestionIndex: -1, + } + case "SET_TAG_INDEX": + return { + ...state, + tagIndex: action.value < 0 || action.value >= state.tags.length ? + (action.value < 0 ? state.tags.length - 1 : 0) : action.value, + } + case "SET_SUGGESTION_INDEX": + return { + ...state, + suggestionIndex: action.value < 0 || action.value >= state.currentSuggestions.length ? + (action.value < 0 ? state.tags.length - 1 : 0) : action.value, } - } - document.addEventListener("click", focusOut) - window.addEventListener("resize", focusOut) - return () => { - document.removeEventListener("click", focusOut) - window.removeEventListener("resize", focusOut) - } - }) - - - const change = (e: ChangeEvent) => { - hideTagsIndex() - setValue(e.target.value) - setCompletions(suggestions - .filter(x => e.target.value.length > 0 && x.startsWith(e.target.value)) - .slice(0, 5)) - } - - const clean = () => { - setValue("") - } - - const select = (val: string) => { - if (val.trim().length !== 0) { - addTags(val) - clean() - } - hideCompletion() - } - - const hideCompletion = () => { - setCompletionIndex(-1) - setCompletions([]) - } - - const hideTagsIndex = () => setTagsIndex(-1) - - const clickTag = (e: any, tagIndex: number) => { - e.preventDefault() - setTagsIndex(tagIndex) - if (value.trim().length !== 0) { - addTags(value) - clean() - } - focusInput() - } - - const clickCompletion = (e: any, completion: string) => { - e.preventDefault() - addTags(completion) - clean() - hideCompletion() - focusInput() - } - - const focusInput = () => { - const input = document.getElementById(idInput) - if (input) return input.focus() } + return state +} - const addTags = (val: string) => updateTags(new Set(Array.from(tags).concat([val.trim()]))) - const removeTags = (val: string) => updateTags(new Set(Array.from(tags).filter(e => e !== val))) - - const key = (e: any) => { - - const completionDown = () => setCompletionIndex(completionIndex + 1 > completions.length - 1 ? 0 : completionIndex + 1) - const completionUp = () => setCompletionIndex(completionIndex - 1 < 0 ? completions.length - 1 : completionIndex - 1) +export default ({ suggestions, placeholder = "", defaultTags = [] }: IProps) => { + const [state, dispatch] = useReducer(reducer, { + value: "", + tags: defaultTags, + suggestions, + currentSuggestions: [], + suggestionOffset: 0, + suggestionIndex: -1, + tagIndex: -1, + }) - const tagsRight = () => setTagsIndex(tagsIndex + 1 > tags.size - 1 ? 0 : tagsIndex + 1) - const tagsLeft = () => setTagsIndex(tagsIndex - 1 < 0 ? tags.size - 1 : tagsIndex - 1) + const [isFocused, setFocused] = useState(false) + const [id] = useState("_" + Math.random().toString(36).substr(2, 9)) - if (e.keyCode === 13) e.preventDefault() - if (e.keyCode === 27 || e.keyCode === 9) { - hideCompletion() - if (e.keyCode !== 9) hideTagsIndex() - if (e.keyCode === 9 && tagsIndex !== -1) { - e.preventDefault() - tagsRight() - } else if (e.keyCode === 9) { - select(value) - setFocused(false) - } - } else if (e.keyCode === 40) completionDown() - else if (e.keyCode === 38) completionUp() - else if (e.keyCode === 37 && value.trim().length === 0) tagsLeft() - else if ((e.keyCode === 39) && value.trim().length === 0) tagsRight() - else if (e.keyCode === 13 && completionIndex !== -1) select(completions[completionIndex]) - else if (e.keyCode === 13 && value.trim().length > 0 && completionIndex === -1) { - select(value) - clean() - } else if (e.keyCode === 8 && value.trim().length === 0) { - if (tags.size > 0 && tagsIndex === -1) { - removeTags(Array.from(tags)[tags.size - 1]) - hideTagsIndex() - } else if (tags.size > 0) { - removeTags(Array.from(tags)[tagsIndex]) - if (tagsIndex === tags.size - 1) - hideTagsIndex() - } + const handleInputKey = (event: KeyboardEvent) => { + if ([32, 13, 186, 188].includes(event.keyCode)) { // space, enter, semi, comma key + event.preventDefault() + dispatch({ + action: "ADD_TAG", + value: state.value, + }) + } else if ([37, 39].includes(event.keyCode)) { // left, right arrow key + event.preventDefault() + dispatch({ + action: "SET_TAG_INDEX", + value: event.keyCode === 37 ? state.tagIndex - 1 : state.tagIndex + 1, + }) + } else if ([38, 40].includes(event.keyCode)) { // up, down arrow key + event.preventDefault() + dispatch({ + action: "SET_SUGGESTION_INDEX", + value: event.keyCode === 38 ? state.suggestionIndex - 1 : state.suggestionIndex + 1, + }) + } else if (event.keyCode === 8 && (state.tagIndex !== -1 || !state.value.trim())) { // del key + event.preventDefault() + dispatch({ + action: "DELETE_TAG", + value: state.tags[state.tagIndex !== -1 ? state.tagIndex : state.tags.length - 1], + }) } } - const sizeOfInput = () => { - const d = document.getElementById(id) - if (d) return d.offsetWidth + "px" - else return "100%" - } - return ( -
- setFocused(true)}> - {Array.from(tags).map((tag, index) => - ( clickTag(e, index)} - style={index !== tagsIndex ? {} : { background: theme.color.dark }} key={index}>{tag})) - } - + <> + + {state.tags.map((tag, index) => ( + dispatch({ action: "DELETE_TAG", value: tag })} + style={index !== state.tagIndex ? {} : { background: theme.color.dark }} key={index}> + {tag} + + ))} + dispatch({ action: "SET_VALUE", value: e.target.value })}/> - {completions.length > 0 && ( - - {completions.map((val, index) => - ( clickCompletion(e, val)} - key={index}>{suggestionRender(val)})) - } - + {state.currentSuggestions.length > 0 && ( + + {state.currentSuggestions.map((value, index) => ( + dispatch({ action: "ADD_TAG", value })} + key={index}> + {value} + + ))} + )} -
+ ) } - From 0ba0b5bff05cd8b2a7c44a338ff30237cecc316c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Caumette?= Date: Sun, 14 Apr 2019 17:42:38 +0200 Subject: [PATCH 21/28] add error on formgroup --- src/components/Container/NewContainer.tsx | 11 +- src/components/Form/FormGroup.tsx | 35 ++++-- src/components/Form/Input.tsx | 9 +- src/components/Form/TagInput.tsx | 129 +++++++++++----------- 4 files changed, 103 insertions(+), 81 deletions(-) diff --git a/src/components/Container/NewContainer.tsx b/src/components/Container/NewContainer.tsx index 535a760..4d5c22f 100644 --- a/src/components/Container/NewContainer.tsx +++ b/src/components/Container/NewContainer.tsx @@ -1,6 +1,6 @@ import React from "react" import { Header } from "../Layout" -import { AutocompleteInput, TagInput, Button, Form, FormGroup, FormSection, Input, Select } from "../Form" +import { AutocompleteInput, Button, Form, FormGroup, FormSection, Input, Select, TagInput } from "../Form" import { Col, Container, Row } from "../Responsive" import { useForm } from "../../hooks" @@ -24,7 +24,7 @@ export default () => {
- + @@ -36,13 +36,14 @@ export default () => { "hello world", "hai!", "Super", "Supra!", "Sjikl", ]} - onChange={(a) => console.log("image:", a.target.value)} /> + onChange={(a) => console.log("image:", a.target.value)}/> - console.log("tags:", a)}/> + console.log("tags:", a)}/> diff --git a/src/components/Form/FormGroup.tsx b/src/components/Form/FormGroup.tsx index 91814d4..c20ecff 100644 --- a/src/components/Form/FormGroup.tsx +++ b/src/components/Form/FormGroup.tsx @@ -1,14 +1,16 @@ import React, { ReactNode } from "react" import { styled } from "../../style" -interface IProps { - name?: string - description?: string - children: ReactNode +interface IFormGroupProps { + error?: boolean } -const FormGroup = styled.div` +const FormGroup = styled.div` margin-bottom: 1rem; + + input, input:focus { + border-color: ${props => props.error ? props.theme.color.red : ""}; + } ` const Label = styled.label` @@ -26,8 +28,22 @@ const Description = styled.small` font-weight: 400; ` -export default ({ name, description, children }: IProps) => ( - +const Error = styled.div` + width: 100%; + margin-top: .25rem; + font-size: 80%; + color: ${props => props.theme.color.red}; +` + +interface IProps { + name?: string + description?: string + error?: string + children: ReactNode +} + +export default ({ name, description, error, children }: IProps) => ( + {name && ( ) diff --git a/src/components/Form/Input.tsx b/src/components/Form/Input.tsx index 516ac5e..9726856 100644 --- a/src/components/Form/Input.tsx +++ b/src/components/Form/Input.tsx @@ -3,13 +3,12 @@ import { styled } from "../../style" export const Input = styled.input` display: block; width: 100%; - height: calc(1.5em + 0.75rem + 2px); - padding: 0.375rem 0.75rem; + padding: 0.575rem 0.75rem; font-size: .9375rem; font-weight: 400; line-height: 1.5; color: ${props => props.theme.text.normal}; - background-color: #fff; + background: ${props => props.theme.color.light}; background-clip: padding-box; border: 1px solid ${props => props.theme.color.grey}; border-radius: 0.25rem; @@ -26,4 +25,6 @@ export const Input = styled.input` } ` -export const Select = Input.withComponent("select") +export const Select = styled(Input.withComponent("select"))` + height: 2.656rem; +` diff --git a/src/components/Form/TagInput.tsx b/src/components/Form/TagInput.tsx index 8256620..839d592 100644 --- a/src/components/Form/TagInput.tsx +++ b/src/components/Form/TagInput.tsx @@ -1,9 +1,8 @@ import { styled, theme } from "../../style" -import React, { KeyboardEvent, ReactNode, useReducer, useState } from "react" +import React, { KeyboardEvent, useReducer, useState } from "react" const Input = styled.input` width: auto; - margin-bottom: 5px; flex-grow: 1; flex-shrink: 0; border: none; @@ -12,42 +11,34 @@ const Input = styled.input` font-size: .9375rem; font-weight: 400; line-height: 1.5; + height: calc(2.6rem); background-clip: padding-box; - - &:disabled, &[readonly] { - background: ${props => props.theme.color.grey}; - } ` -const Tag = styled.p` - margin-bottom: 0.375rem; - border-radius: 3px; - flex-grow:0; - flex-shrink:0; - background: ${theme.color.greyDark}; - color: white; - display: inline-block; - max-width: 100%; - word-wrap: break-word; - padding: 0 0.5rem; - margin-right: 0.5rem; +const Tag = styled.div` + display: inline; + background: ${props => props.theme.color.dark}; + color: ${props => props.theme.color.light}; + padding: 0.2rem 0.8rem; + margin-right: 5px; + border-radius: 15px; cursor: pointer; ` -const Autocomplete = styled.div` +interface IAutocompleteProps { + focus?: boolean +} + +const Autocomplete = styled.div` display: flex; flex-flow: row wrap; width: 100%; - padding: 0.375rem 0.75rem 0; - font-size: .9375rem; - font-weight: 400; - line-height: 1.5; color: ${props => props.theme.text.normal}; - background-color: #fff; - background-clip: padding-box; - border: 1px solid ${props => props.theme.color.grey}; + background-color: ${props => props.theme.color.light}; + border: 1px solid ${props => props.focus ? props.theme.color.blue : props.theme.color.grey}; border-radius: 0.25rem; transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; + padding: 0.575rem 0.75rem; &:disabled, &[readonly] { background: ${props => props.theme.color.grey}; @@ -55,13 +46,23 @@ const Autocomplete = styled.div` ` const SuggestionList = styled.div` - margin-top: 10px; - border-radius: 0.25rem; position: absolute; - display: block; - z-index: 99; - border: 1px solid ${theme.color.grey}; + will-change: transform; + top: 0; + left: 0; width: 100%; + transform: translate3d(0, 38px, 0); + border: 1px solid #edf2f9; + box-shadow: 0 0.75rem 1.5rem rgba(18, 38, 63, 0.03); + background-color: ${props => props.theme.color.light}; + border-radius: .25rem; + padding: .4rem 0; + z-index: 1000; + float: left; +` + +const TagInput = styled.div` + position: relative; ` const Suggestion = styled.div` @@ -74,15 +75,6 @@ const Suggestion = styled.div` } ` -interface IProps { - suggestions: string[] - onChange: (event: string[]) => void - name: string - placeholder?: string - defaultTags?: string[] - suggestionRender?: (suggest: string) => ReactNode -} - interface IState { value: string tags: string[] @@ -106,8 +98,8 @@ const reducer = (state: IState, action: Action) => { return { ...state, value: action.value, - currentSuggestions: action.value.trim() ? - state.suggestions.filter(s => s.startsWith(action.value)) : [], + currentSuggestions: action.value.trim() ? state.suggestions + .filter(s => s.startsWith(action.value) && !state.tags.includes(s)).slice(0, 5) : [], tagIndex: -1, suggestionIndex: -1, } @@ -117,6 +109,7 @@ const reducer = (state: IState, action: Action) => { value: "", tags: state.tags.includes(action.value) || !action.value.trim() ? state.tags : [...state.tags, action.value], + currentSuggestions: [], tagIndex: -1, suggestionIndex: -1, } @@ -143,41 +136,49 @@ const reducer = (state: IState, action: Action) => { return state } -export default ({ suggestions, placeholder = "", defaultTags = [] }: IProps) => { +interface IProps { + suggestions: string[] + value?: string[] + placeholder?: string + onChange?: (value: string[]) => any +} + +export default ({ onChange, suggestions, placeholder = "", value = [] }: IProps) => { const [state, dispatch] = useReducer(reducer, { value: "", - tags: defaultTags, + tags: value, suggestions, currentSuggestions: [], suggestionOffset: 0, suggestionIndex: -1, tagIndex: -1, }) + const [focus, setFocus] = useState(false) - const [isFocused, setFocused] = useState(false) - const [id] = useState("_" + Math.random().toString(36).substr(2, 9)) - + if (onChange && state.tags.length) { + onChange(state.tags) + } const handleInputKey = (event: KeyboardEvent) => { - if ([32, 13, 186, 188].includes(event.keyCode)) { // space, enter, semi, comma key + if ([" ", "Enter", ";", ","].includes(event.key)) { event.preventDefault() dispatch({ action: "ADD_TAG", - value: state.value, + value: state.suggestionIndex !== -1 ? state.currentSuggestions[state.suggestionIndex] : state.value, }) - } else if ([37, 39].includes(event.keyCode)) { // left, right arrow key + } else if (["ArrowLeft", "ArrowRight"].includes(event.key) && (state.tagIndex !== -1 || !state.value.trim())) { event.preventDefault() dispatch({ action: "SET_TAG_INDEX", - value: event.keyCode === 37 ? state.tagIndex - 1 : state.tagIndex + 1, + value: event.key === "ArrowLeft" ? state.tagIndex - 1 : state.tagIndex + 1, }) - } else if ([38, 40].includes(event.keyCode)) { // up, down arrow key + } else if (["ArrowUp", "ArrowDown"].includes(event.key)) { event.preventDefault() dispatch({ action: "SET_SUGGESTION_INDEX", - value: event.keyCode === 38 ? state.suggestionIndex - 1 : state.suggestionIndex + 1, + value: event.key === "ArrowUp" ? state.suggestionIndex - 1 : state.suggestionIndex + 1, }) - } else if (event.keyCode === 8 && (state.tagIndex !== -1 || !state.value.trim())) { // del key + } else if (event.key === "Backspace" && (state.tagIndex !== -1 || !state.value.trim())) { event.preventDefault() dispatch({ action: "DELETE_TAG", @@ -187,29 +188,27 @@ export default ({ suggestions, placeholder = "", defaultTags = [] }: IProps) => } return ( - <> - + setFocus(true)} onBlur={() => setFocus(true)}> + {state.tags.map((tag, index) => ( - dispatch({ action: "DELETE_TAG", value: tag })} - style={index !== state.tagIndex ? {} : { background: theme.color.dark }} key={index}> + dispatch({ action: "DELETE_TAG", value: tag })} + style={index !== state.tagIndex ? {} : { background: theme.color.dark }}> {tag} ))} - dispatch({ action: "SET_VALUE", value: e.target.value })}/> - {state.currentSuggestions.length > 0 && ( + {(state.currentSuggestions.length > 0 && focus) && ( {state.currentSuggestions.map((value, index) => ( - dispatch({ action: "ADD_TAG", value })} - key={index}> + dispatch({ action: "ADD_TAG", value })} + style={index === state.suggestionIndex ? { background: theme.color.greyLight } : {}}> {value} ))} )} - + ) } From 9ba9c093285ffb2dc4c3516ba0b88246d996591c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Caumette?= Date: Mon, 15 Apr 2019 13:59:50 +0200 Subject: [PATCH 22/28] refactor account route --- src/client.ts | 20 +++++++++- src/components/Account/Account.tsx | 38 ++++++++++--------- src/components/Container/NewContainer.tsx | 46 +++++++++++++++++------ src/components/Form/Button.tsx | 2 +- src/components/Form/TagInput.tsx | 13 ++++--- src/components/Loader/Loader.tsx | 4 +- src/components/Responsive/Row.tsx | 1 + 7 files changed, 86 insertions(+), 38 deletions(-) diff --git a/src/client.ts b/src/client.ts index 83f3c55..2d3a522 100644 --- a/src/client.ts +++ b/src/client.ts @@ -11,7 +11,7 @@ export interface IAccount { } export interface IContainer { - key: string + id: string name: string status: string image: string @@ -21,6 +21,15 @@ export interface IContainer { createdAt: Date } +export interface IContainerPlan { + id: string + name: string + price: number + cpu: number + memory: number + available: boolean +} + export interface CreateContainerRequest { name: string image: string @@ -102,3 +111,12 @@ export const createContainer = (req: CreateContainerRequest): Promise => + client.get("/v1/containers/plans") + .then((res) => { + if (res.status !== 200) { + throw new Error(res.data.message) + } + return res.data.plans + }) diff --git a/src/components/Account/Account.tsx b/src/components/Account/Account.tsx index 6c5f220..56b96c4 100644 --- a/src/components/Account/Account.tsx +++ b/src/components/Account/Account.tsx @@ -1,9 +1,9 @@ import React, { useState } from "react" import { getAccount, regenerateApiKey, syncAccount } from "../../client" import { Header } from "../Layout" -import { FormGroup, FormSection, Input } from "../Form" +import { Button, FormGroup, FormSection, Input } from "../Form" import { usePromise } from "../../hooks" -import { Container } from "../Responsive" +import { Col, Container, Row } from "../Responsive" import { Loader } from "../Loader" export default () => { @@ -34,7 +34,7 @@ export default () => { setApiError(error) }) } - +// form-row form-group return ( <>
@@ -57,27 +57,29 @@ export default () => { - + -
-
- -
-
- -
-
+ + + - +
diff --git a/src/components/Container/NewContainer.tsx b/src/components/Container/NewContainer.tsx index 4d5c22f..7b408ad 100644 --- a/src/components/Container/NewContainer.tsx +++ b/src/components/Container/NewContainer.tsx @@ -2,18 +2,37 @@ import React from "react" import { Header } from "../Layout" import { AutocompleteInput, Button, Form, FormGroup, FormSection, Input, Select, TagInput } from "../Form" import { Col, Container, Row } from "../Responsive" -import { useForm } from "../../hooks" +import { useForm, usePromise } from "../../hooks" +import { getContainerPlans } from "../../client" +import { styled } from "../../style" const test = () => new Promise((resolve, reject) => setTimeout(resolve, 4000)) +const PlanTable = styled.table` + width: 100%; +` + +const Plan = styled.tr` + border: 1px solid ${props => props.theme.color.grey}; + border-radius: 0.25rem; + + td { + padding: 0.5rem 1rem; + } +` + export default () => { + const { data, loading: load } = usePromise(() => Promise.all([ + getContainerPlans(), + ]), []) + const { loading, error, handleChange, handleSubmit, dispatch, values } = useForm({ name: "", image: "", size: "", tags: "", }, async (values) => { - await test() + // await test() }) return ( @@ -21,10 +40,10 @@ export default () => {
- + - + @@ -49,13 +68,18 @@ export default () => { - - - + + + {data && data[0].map((plan, index) => ( + + {plan.name} + {plan.cpu} vCPU + {plan.memory}MB + ${plan.price} + + ))} + + diff --git a/src/components/Form/Button.tsx b/src/components/Form/Button.tsx index 6f32033..d7e5b37 100644 --- a/src/components/Form/Button.tsx +++ b/src/components/Form/Button.tsx @@ -19,7 +19,7 @@ export const Button = styled.button` user-select: none; background-color: transparent; border: 1px solid transparent; - padding: 0.375rem 0.75rem; + padding: 0.5rem 0.75rem; font-size: 1rem; line-height: 1.5; border-radius: 0.25rem; diff --git a/src/components/Form/TagInput.tsx b/src/components/Form/TagInput.tsx index 839d592..d14a5bb 100644 --- a/src/components/Form/TagInput.tsx +++ b/src/components/Form/TagInput.tsx @@ -1,5 +1,5 @@ import { styled, theme } from "../../style" -import React, { KeyboardEvent, useReducer, useState } from "react" +import React, { KeyboardEvent, useEffect, useReducer, useState } from "react" const Input = styled.input` width: auto; @@ -155,9 +155,12 @@ export default ({ onChange, suggestions, placeholder = "", value = [] }: IProps) }) const [focus, setFocus] = useState(false) - if (onChange && state.tags.length) { - onChange(state.tags) - } + + useEffect(() => { + if (onChange && state.tags.length) { + onChange(state.tags) + } + }, [state.tags]) const handleInputKey = (event: KeyboardEvent) => { if ([" ", "Enter", ";", ","].includes(event.key)) { @@ -188,7 +191,7 @@ export default ({ onChange, suggestions, placeholder = "", value = [] }: IProps) } return ( - setFocus(true)} onBlur={() => setFocus(true)}> + setFocus(true)} onBlur={() => setFocus(false)}> {state.tags.map((tag, index) => ( dispatch({ action: "DELETE_TAG", value: tag })} diff --git a/src/components/Loader/Loader.tsx b/src/components/Loader/Loader.tsx index 7825748..271fc1a 100644 --- a/src/components/Loader/Loader.tsx +++ b/src/components/Loader/Loader.tsx @@ -9,7 +9,7 @@ interface IProps { const Loader = styled.div` position: absolute; - top: 50%; + top: calc(50% - 80px); left: 50%; z-index: 10000; height: 5em; @@ -59,7 +59,7 @@ const Spinner = styled.div` export default ({ loading = true, children }: IProps) => { return ( -
+
{loading && ( diff --git a/src/components/Responsive/Row.tsx b/src/components/Responsive/Row.tsx index 39aeb8e..ce78f36 100644 --- a/src/components/Responsive/Row.tsx +++ b/src/components/Responsive/Row.tsx @@ -12,4 +12,5 @@ export default styled.div(props => css` flex-wrap: wrap; align-content: ${props.alignContent || "inherit"}; justify-content: ${props.justifyContent || "inherit"}; + margin: 0 -15px; `) From f478f84c271a31943b4a69fde1235309066936c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Caumette?= Date: Tue, 16 Apr 2019 14:45:11 +0200 Subject: [PATCH 23/28] add redux and account reducer --- package-lock.json | 1031 ++++++++++++++++---- package.json | 4 +- src/client.ts | 42 +- src/components/App.tsx | 50 +- src/components/Card/CardTable.tsx | 10 +- src/components/Container/ListContainer.tsx | 10 +- src/components/Image/ListImage.tsx | 129 ++- src/components/Layout/Navbar.tsx | 7 +- src/index.tsx | 16 +- src/reducers/account.ts | 24 + src/reducers/index.ts | 4 + 11 files changed, 1069 insertions(+), 258 deletions(-) create mode 100644 src/reducers/account.ts create mode 100644 src/reducers/index.ts diff --git a/package-lock.json b/package-lock.json index 53c2cba..02f5091 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1220,14 +1220,58 @@ } }, "@svgr/plugin-svgo": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/@svgr/plugin-svgo/-/plugin-svgo-4.0.3.tgz", - "integrity": "sha512-MgL1CrlxvNe+1tQjPUc2bIJtsdJOIE5arbHlPgW+XVWGjMZTUcyNNP8R7/IjM2Iyrc98UJY+WYiiWHrinnY9ZQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@svgr/plugin-svgo/-/plugin-svgo-4.2.0.tgz", + "integrity": "sha512-zUEKgkT172YzHh3mb2B2q92xCnOAMVjRx+o0waZ1U50XqKLrVQ/8dDqTAtnmapdLsGurv8PSwenjLCUpj6hcvw==", "dev": true, "requires": { - "cosmiconfig": "^5.0.7", + "cosmiconfig": "^5.2.0", "merge-deep": "^3.0.2", - "svgo": "^1.1.1" + "svgo": "^1.2.1" + }, + "dependencies": { + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "svgo": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-1.2.1.tgz", + "integrity": "sha512-Y1+LyT4/y1ms4/0yxPMSlvx6dIbgklE9w8CIOnfeoFGB74MEkq8inSfEr6NhocTaFbyYp0a1dvNgRKGRmEBlzA==", + "dev": true, + "requires": { + "chalk": "^2.4.1", + "coa": "^2.0.2", + "css-select": "^2.0.0", + "css-select-base-adapter": "^0.1.1", + "css-tree": "1.0.0-alpha.28", + "css-url-regex": "^1.1.0", + "csso": "^3.5.1", + "js-yaml": "^3.13.0", + "mkdirp": "~0.5.1", + "object.values": "^1.1.0", + "sax": "~1.2.4", + "stable": "^0.1.8", + "unquote": "~1.1.1", + "util.promisify": "~1.0.0" + } + } } }, "@svgr/webpack": { @@ -7588,9 +7632,9 @@ "dev": true }, "fsevents": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.4.tgz", - "integrity": "sha512-z8H8/diyk76B7q5wg+Ud0+CqzcAF3mBBI/bA5ne5zrRUUIvNkJY//D3BqyH571KuAC4Nr7Rw7CjWX4r0y9DvNg==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.7.tgz", + "integrity": "sha512-Pxm6sI2MeBD7RdD12RYsqaP0nMiwx8eZBXCa6z2L+mRHm2DYrOYwihmhjpkdjUHwQhslWQjRpEgNq4XvBmaAuw==", "dev": true, "optional": true, "requires": { @@ -7617,7 +7661,7 @@ "optional": true }, "are-we-there-yet": { - "version": "1.1.4", + "version": "1.1.5", "bundled": true, "dev": true, "optional": true, @@ -7643,7 +7687,7 @@ } }, "chownr": { - "version": "1.0.1", + "version": "1.1.1", "bundled": true, "dev": true, "optional": true @@ -7682,7 +7726,7 @@ } }, "deep-extend": { - "version": "0.5.1", + "version": "0.6.0", "bundled": true, "dev": true, "optional": true @@ -7731,7 +7775,7 @@ } }, "glob": { - "version": "7.1.2", + "version": "7.1.3", "bundled": true, "dev": true, "optional": true, @@ -7751,12 +7795,12 @@ "optional": true }, "iconv-lite": { - "version": "0.4.21", + "version": "0.4.24", "bundled": true, "dev": true, "optional": true, "requires": { - "safer-buffer": "^2.1.0" + "safer-buffer": ">= 2.1.2 < 3" } }, "ignore-walk": { @@ -7821,17 +7865,17 @@ "optional": true }, "minipass": { - "version": "2.2.4", + "version": "2.3.5", "bundled": true, "dev": true, "optional": true, "requires": { - "safe-buffer": "^5.1.1", + "safe-buffer": "^5.1.2", "yallist": "^3.0.0" } }, "minizlib": { - "version": "1.1.0", + "version": "1.2.1", "bundled": true, "dev": true, "optional": true, @@ -7855,7 +7899,7 @@ "optional": true }, "needle": { - "version": "2.2.0", + "version": "2.2.4", "bundled": true, "dev": true, "optional": true, @@ -7866,18 +7910,18 @@ } }, "node-pre-gyp": { - "version": "0.10.0", + "version": "0.10.3", "bundled": true, "dev": true, "optional": true, "requires": { "detect-libc": "^1.0.2", "mkdirp": "^0.5.1", - "needle": "^2.2.0", + "needle": "^2.2.1", "nopt": "^4.0.1", "npm-packlist": "^1.1.6", "npmlog": "^4.0.2", - "rc": "^1.1.7", + "rc": "^1.2.7", "rimraf": "^2.6.1", "semver": "^5.3.0", "tar": "^4" @@ -7894,13 +7938,13 @@ } }, "npm-bundled": { - "version": "1.0.3", + "version": "1.0.5", "bundled": true, "dev": true, "optional": true }, "npm-packlist": { - "version": "1.1.10", + "version": "1.2.0", "bundled": true, "dev": true, "optional": true, @@ -7977,12 +8021,12 @@ "optional": true }, "rc": { - "version": "1.2.7", + "version": "1.2.8", "bundled": true, "dev": true, "optional": true, "requires": { - "deep-extend": "^0.5.1", + "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" @@ -8012,16 +8056,16 @@ } }, "rimraf": { - "version": "2.6.2", + "version": "2.6.3", "bundled": true, "dev": true, "optional": true, "requires": { - "glob": "^7.0.5" + "glob": "^7.1.3" } }, "safe-buffer": { - "version": "5.1.1", + "version": "5.1.2", "bundled": true, "dev": true, "optional": true @@ -8039,7 +8083,7 @@ "optional": true }, "semver": { - "version": "5.5.0", + "version": "5.6.0", "bundled": true, "dev": true, "optional": true @@ -8092,17 +8136,17 @@ "optional": true }, "tar": { - "version": "4.4.1", + "version": "4.4.8", "bundled": true, "dev": true, "optional": true, "requires": { - "chownr": "^1.0.1", + "chownr": "^1.1.1", "fs-minipass": "^1.2.5", - "minipass": "^2.2.4", - "minizlib": "^1.1.0", + "minipass": "^2.3.4", + "minizlib": "^1.1.1", "mkdirp": "^0.5.0", - "safe-buffer": "^5.1.1", + "safe-buffer": "^5.1.2", "yallist": "^3.0.2" } }, @@ -8113,12 +8157,12 @@ "optional": true }, "wide-align": { - "version": "1.1.2", + "version": "1.1.3", "bundled": true, "dev": true, "optional": true, "requires": { - "string-width": "^1.0.2" + "string-width": "^1.0.2 || 2" } }, "wrappy": { @@ -8128,7 +8172,7 @@ "optional": true }, "yallist": { - "version": "3.0.2", + "version": "3.0.3", "bundled": true, "dev": true, "optional": true @@ -8319,23 +8363,15 @@ "dev": true }, "handlebars": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.1.1.tgz", - "integrity": "sha512-3Zhi6C0euYZL5sM0Zcy7lInLXKQ+YLcF/olbN010mzGQ4XVm50JeyBnMqofHh696GrciGruC7kCcApPDJvVgwA==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.1.2.tgz", + "integrity": "sha512-nvfrjqvt9xQ8Z/w0ijewdD/vvWDTOweBUm96NTr66Wfvo1mJenBLwcYmPs3TIBP5ruzYGD7Hx/DaM9RmhroGPw==", "dev": true, "requires": { "neo-async": "^2.6.0", "optimist": "^0.6.1", "source-map": "^0.6.1", "uglify-js": "^3.1.4" - }, - "dependencies": { - "source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true - } } }, "har-schema": { @@ -10857,9 +10893,9 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, "js-yaml": { - "version": "3.13.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.0.tgz", - "integrity": "sha512-pZZoSxcCYco+DIKBTimr67J6Hy+EYGZDY/HCWC+iAEA9h1ByhMXAIVUXMcMFpOCxQ/xjXmPI2MkDL5HRm5eFrQ==", + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz", + "integrity": "sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==", "requires": { "argparse": "^1.0.7", "esprima": "^4.0.0" @@ -17492,183 +17528,733 @@ "webpack-dev-server": "3.1.14", "webpack-manifest-plugin": "2.0.4", "workbox-webpack-plugin": "3.6.3" - } - }, - "react-timeago": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/react-timeago/-/react-timeago-4.4.0.tgz", - "integrity": "sha512-Zj8RchTqZEH27LAANemzMR2RpotbP2aMd+UIajfYMZ9KW4dMcViUVKzC7YmqfiqlFfz8B0bjDw2xUBjmcxDngA==" - }, - "read-pkg": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-1.1.0.tgz", - "integrity": "sha1-9f+qXs0pyzHAR0vKfXVra7KePyg=", - "dev": true, - "requires": { - "load-json-file": "^1.0.0", - "normalize-package-data": "^2.3.2", - "path-type": "^1.0.0" - } - }, - "read-pkg-up": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-1.0.1.tgz", - "integrity": "sha1-nWPBMnbAZZGNV/ACpX9AobZD+wI=", - "dev": true, - "requires": { - "find-up": "^1.0.0", - "read-pkg": "^1.0.0" - } - }, - "readable-stream": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", - "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", - "dev": true, - "requires": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - }, - "dependencies": { - "isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", - "dev": true - } - } - }, - "readdirp": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-2.2.1.tgz", - "integrity": "sha512-1JU/8q+VgFZyxwrJ+SVIOsh+KywWGpds3NTqikiKpDMZWScmAYyKIgqkO+ARvNWJfXeXR1zxz7aHF4u4CyH6vQ==", - "dev": true, - "requires": { - "graceful-fs": "^4.1.11", - "micromatch": "^3.1.10", - "readable-stream": "^2.0.2" }, "dependencies": { - "arr-diff": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz", - "integrity": "sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA=", - "dev": true - }, - "array-unique": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.3.2.tgz", - "integrity": "sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg=", - "dev": true - }, - "braces": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz", - "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==", + "fsevents": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.4.tgz", + "integrity": "sha512-z8H8/diyk76B7q5wg+Ud0+CqzcAF3mBBI/bA5ne5zrRUUIvNkJY//D3BqyH571KuAC4Nr7Rw7CjWX4r0y9DvNg==", "dev": true, + "optional": true, "requires": { - "arr-flatten": "^1.1.0", - "array-unique": "^0.3.2", - "extend-shallow": "^2.0.1", - "fill-range": "^4.0.0", - "isobject": "^3.0.1", - "repeat-element": "^1.1.2", - "snapdragon": "^0.8.1", - "snapdragon-node": "^2.0.1", - "split-string": "^3.0.2", - "to-regex": "^3.0.1" + "nan": "^2.9.2", + "node-pre-gyp": "^0.10.0" }, "dependencies": { - "extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "abbrev": { + "version": "1.1.1", + "bundled": true, "dev": true, - "requires": { - "is-extendable": "^0.1.0" - } - } - } - }, - "debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "requires": { - "ms": "2.0.0" - } - }, - "expand-brackets": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-2.1.4.tgz", - "integrity": "sha1-t3c14xXOMPa27/D4OwQVGiJEliI=", - "dev": true, - "requires": { - "debug": "^2.3.3", - "define-property": "^0.2.5", - "extend-shallow": "^2.0.1", - "posix-character-classes": "^0.1.0", - "regex-not": "^1.0.0", - "snapdragon": "^0.8.1", - "to-regex": "^3.0.1" - }, - "dependencies": { - "define-property": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", - "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "optional": true + }, + "ansi-regex": { + "version": "2.1.1", + "bundled": true, "dev": true, - "requires": { - "is-descriptor": "^0.1.0" - } + "optional": true }, - "extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "aproba": { + "version": "1.2.0", + "bundled": true, + "dev": true, + "optional": true + }, + "are-we-there-yet": { + "version": "1.1.4", + "bundled": true, "dev": true, + "optional": true, "requires": { - "is-extendable": "^0.1.0" + "delegates": "^1.0.0", + "readable-stream": "^2.0.6" } }, - "is-accessor-descriptor": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz", - "integrity": "sha1-qeEss66Nh2cn7u84Q/igiXtcmNY=", + "balanced-match": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "brace-expansion": { + "version": "1.1.11", + "bundled": true, "dev": true, + "optional": true, "requires": { - "kind-of": "^3.0.2" - }, - "dependencies": { - "kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", - "dev": true, - "requires": { - "is-buffer": "^1.1.5" - } - } + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" } }, - "is-data-descriptor": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz", - "integrity": "sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y=", + "chownr": { + "version": "1.0.1", + "bundled": true, "dev": true, - "requires": { - "kind-of": "^3.0.2" - }, - "dependencies": { - "kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "optional": true + }, + "code-point-at": { + "version": "1.1.0", + "bundled": true, + "dev": true, + "optional": true + }, + "concat-map": { + "version": "0.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "console-control-strings": { + "version": "1.1.0", + "bundled": true, + "dev": true, + "optional": true + }, + "core-util-is": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "debug": { + "version": "2.6.9", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "ms": "2.0.0" + } + }, + "deep-extend": { + "version": "0.5.1", + "bundled": true, + "dev": true, + "optional": true + }, + "delegates": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "detect-libc": { + "version": "1.0.3", + "bundled": true, + "dev": true, + "optional": true + }, + "fs-minipass": { + "version": "1.2.5", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "minipass": "^2.2.1" + } + }, + "fs.realpath": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "gauge": { + "version": "2.7.4", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "aproba": "^1.0.3", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.0", + "object-assign": "^4.1.0", + "signal-exit": "^3.0.0", + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1", + "wide-align": "^1.1.0" + } + }, + "glob": { + "version": "7.1.2", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "has-unicode": { + "version": "2.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "iconv-lite": { + "version": "0.4.21", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "safer-buffer": "^2.1.0" + } + }, + "ignore-walk": { + "version": "3.0.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "minimatch": "^3.0.4" + } + }, + "inflight": { + "version": "1.0.6", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.3", + "bundled": true, + "dev": true, + "optional": true + }, + "ini": { + "version": "1.3.5", + "bundled": true, + "dev": true, + "optional": true + }, + "is-fullwidth-code-point": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "number-is-nan": "^1.0.0" + } + }, + "isarray": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "minimatch": { + "version": "3.0.4", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "minimist": { + "version": "0.0.8", + "bundled": true, + "dev": true, + "optional": true + }, + "minipass": { + "version": "2.2.4", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "safe-buffer": "^5.1.1", + "yallist": "^3.0.0" + } + }, + "minizlib": { + "version": "1.1.0", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "minipass": "^2.2.1" + } + }, + "mkdirp": { + "version": "0.5.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "minimist": "0.0.8" + } + }, + "ms": { + "version": "2.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "needle": { + "version": "2.2.0", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "debug": "^2.1.2", + "iconv-lite": "^0.4.4", + "sax": "^1.2.4" + } + }, + "node-pre-gyp": { + "version": "0.10.0", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "detect-libc": "^1.0.2", + "mkdirp": "^0.5.1", + "needle": "^2.2.0", + "nopt": "^4.0.1", + "npm-packlist": "^1.1.6", + "npmlog": "^4.0.2", + "rc": "^1.1.7", + "rimraf": "^2.6.1", + "semver": "^5.3.0", + "tar": "^4" + } + }, + "nopt": { + "version": "4.0.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "abbrev": "1", + "osenv": "^0.1.4" + } + }, + "npm-bundled": { + "version": "1.0.3", + "bundled": true, + "dev": true, + "optional": true + }, + "npm-packlist": { + "version": "1.1.10", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "ignore-walk": "^3.0.1", + "npm-bundled": "^1.0.1" + } + }, + "npmlog": { + "version": "4.1.2", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "are-we-there-yet": "~1.1.2", + "console-control-strings": "~1.1.0", + "gauge": "~2.7.3", + "set-blocking": "~2.0.0" + } + }, + "number-is-nan": { + "version": "1.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "object-assign": { + "version": "4.1.1", + "bundled": true, + "dev": true, + "optional": true + }, + "once": { + "version": "1.4.0", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "wrappy": "1" + } + }, + "os-homedir": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "os-tmpdir": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "osenv": { + "version": "0.1.5", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "os-homedir": "^1.0.0", + "os-tmpdir": "^1.0.0" + } + }, + "path-is-absolute": { + "version": "1.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "process-nextick-args": { + "version": "2.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "rc": { + "version": "1.2.7", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "deep-extend": "^0.5.1", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "dependencies": { + "minimist": { + "version": "1.2.0", + "bundled": true, + "dev": true, + "optional": true + } + } + }, + "readable-stream": { + "version": "2.3.6", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "rimraf": { + "version": "2.6.2", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "glob": "^7.0.5" + } + }, + "safe-buffer": { + "version": "5.1.1", + "bundled": true, + "dev": true, + "optional": true + }, + "safer-buffer": { + "version": "2.1.2", + "bundled": true, + "dev": true, + "optional": true + }, + "sax": { + "version": "1.2.4", + "bundled": true, + "dev": true, + "optional": true + }, + "semver": { + "version": "5.5.0", + "bundled": true, + "dev": true, + "optional": true + }, + "set-blocking": { + "version": "2.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "signal-exit": { + "version": "3.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "string-width": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + } + }, + "string_decoder": { + "version": "1.1.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "safe-buffer": "~5.1.0" + } + }, + "strip-ansi": { + "version": "3.0.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "strip-json-comments": { + "version": "2.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "tar": { + "version": "4.4.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "chownr": "^1.0.1", + "fs-minipass": "^1.2.5", + "minipass": "^2.2.4", + "minizlib": "^1.1.0", + "mkdirp": "^0.5.0", + "safe-buffer": "^5.1.1", + "yallist": "^3.0.2" + } + }, + "util-deprecate": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "wide-align": { + "version": "1.1.2", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "string-width": "^1.0.2" + } + }, + "wrappy": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "yallist": { + "version": "3.0.2", + "bundled": true, + "dev": true, + "optional": true + } + } + } + } + }, + "react-timeago": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/react-timeago/-/react-timeago-4.4.0.tgz", + "integrity": "sha512-Zj8RchTqZEH27LAANemzMR2RpotbP2aMd+UIajfYMZ9KW4dMcViUVKzC7YmqfiqlFfz8B0bjDw2xUBjmcxDngA==" + }, + "read-pkg": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-1.1.0.tgz", + "integrity": "sha1-9f+qXs0pyzHAR0vKfXVra7KePyg=", + "dev": true, + "requires": { + "load-json-file": "^1.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^1.0.0" + } + }, + "read-pkg-up": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-1.0.1.tgz", + "integrity": "sha1-nWPBMnbAZZGNV/ACpX9AobZD+wI=", + "dev": true, + "requires": { + "find-up": "^1.0.0", + "read-pkg": "^1.0.0" + } + }, + "readable-stream": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + }, + "dependencies": { + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + } + } + }, + "readdirp": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-2.2.1.tgz", + "integrity": "sha512-1JU/8q+VgFZyxwrJ+SVIOsh+KywWGpds3NTqikiKpDMZWScmAYyKIgqkO+ARvNWJfXeXR1zxz7aHF4u4CyH6vQ==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.11", + "micromatch": "^3.1.10", + "readable-stream": "^2.0.2" + }, + "dependencies": { + "arr-diff": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz", + "integrity": "sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA=", + "dev": true + }, + "array-unique": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.3.2.tgz", + "integrity": "sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg=", + "dev": true + }, + "braces": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz", + "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==", + "dev": true, + "requires": { + "arr-flatten": "^1.1.0", + "array-unique": "^0.3.2", + "extend-shallow": "^2.0.1", + "fill-range": "^4.0.0", + "isobject": "^3.0.1", + "repeat-element": "^1.1.2", + "snapdragon": "^0.8.1", + "snapdragon-node": "^2.0.1", + "split-string": "^3.0.2", + "to-regex": "^3.0.1" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "expand-brackets": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-2.1.4.tgz", + "integrity": "sha1-t3c14xXOMPa27/D4OwQVGiJEliI=", + "dev": true, + "requires": { + "debug": "^2.3.3", + "define-property": "^0.2.5", + "extend-shallow": "^2.0.1", + "posix-character-classes": "^0.1.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + }, + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + }, + "is-accessor-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz", + "integrity": "sha1-qeEss66Nh2cn7u84Q/igiXtcmNY=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "is-data-descriptor": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz", + "integrity": "sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", "dev": true, "requires": { @@ -17857,6 +18443,20 @@ "minimatch": "3.0.4" } }, + "redux": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.0.1.tgz", + "integrity": "sha512-R7bAtSkk7nY6O/OYMVR9RiBI+XghjF9rlbl5806HJbQph0LJVHZrU5oaO4q70eUKiqMRqm4y07KLTlMZ2BlVmg==", + "requires": { + "loose-envify": "^1.4.0", + "symbol-observable": "^1.2.0" + } + }, + "redux-react-hook": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/redux-react-hook/-/redux-react-hook-3.3.1.tgz", + "integrity": "sha512-a5RHGYT2ZV8Zo1ATP2ubxQGm0fPwn62loSieTeCL7PRgGfdTi8jR0S1K+1/1b4P6RSnEXvs5qC9IVlh2clPbxg==" + }, "regenerate": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.0.tgz", @@ -19097,6 +19697,12 @@ "integrity": "sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==", "dev": true }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + }, "source-map-resolve": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.5.2.tgz", @@ -19600,6 +20206,11 @@ } } }, + "symbol-observable": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz", + "integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==" + }, "symbol-tree": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.2.tgz", diff --git a/package.json b/package.json index a5fcaad..f1cca8d 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,9 @@ "react": "^16.8.1", "react-dom": "^16.8.1", "react-router-dom": "^4.3.1", - "react-timeago": "^4.3.0" + "react-timeago": "^4.3.0", + "redux": "^4.0.1", + "redux-react-hook": "^3.3.1" }, "eslintConfig": { "extends": "react-app" diff --git a/src/client.ts b/src/client.ts index 2d3a522..212c62a 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,13 +1,13 @@ import axios from "axios" export interface IAccount { - id: string; - name: string; - email: string; - avatarUrl: string; - apiKey: string; - admin: boolean; - createdAt: Date; + id: string + name: string + email: string + avatarUrl: string + apiKey: string + admin: boolean + createdAt: Date } export interface IContainer { @@ -37,6 +37,16 @@ export interface CreateContainerRequest { tags: string[] } +export interface IImage { + id: string + name: string + tag: string + layers: number + size: number + digest: string + createdAt: Date +} + const remapFields = (obj: any, fields: any): any => Object.entries(obj) .map(([key, value]) => [fields[key] || key, value]) @@ -52,6 +62,15 @@ const toAccount = (data: object): IAccount => { return account } +const toImage = (data: object): IImage => { + const image = remapFields(data, { + image_id: "id", + created_at: "createdAt", + }) + image.createdAt = new Date(image.createdAt) + return image +} + const toContainer = (data: object): IContainer => { const container = remapFields(data, { created_at: "createdAt", @@ -120,3 +139,12 @@ export const getContainerPlans = (): Promise => } return res.data.plans }) + +export const getImages = (): Promise => + client.get("/v1/images").then((res) => { + if (res.status !== 200) { + throw new Error(res.data.message) + } + console.log(res.data.images) + return res.data.images.map((data: object) => toImage(data)) + }) diff --git a/src/components/App.tsx b/src/components/App.tsx index 53335af..ba1dd37 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -1,24 +1,42 @@ import React from "react" import { Redirect, Route, Switch } from "react-router-dom" -import { Navbar, Footer } from "./Layout" +import { Footer, Navbar } from "./Layout" import { ListContainer, NewContainer } from "./Container" import { ListImage } from "./Image" import { Account } from "./Account" +import { getAccount } from "../client" +import { usePromise } from "../hooks" +import { useDispatch } from "redux-react-hook" -export default () => ( - <> - +export default () => { + const { data, loading, error } = usePromise(() => getAccount(), []) + const dispatch = useDispatch() - - - - - - - + if (data) { + dispatch({ type: "SET_ACCOUNT", account: data }) + } else if (error) { + window.location.href = process.env.AUTH_URL || "http://localhost:3002" + } -
- © Expected.sh - All Rights Reserved 2019 -
- -) + return loading ? ( +

+ Loading... +

+ ) : ( + <> + + + + + + + + + + +
+ © Expected.sh - All Rights Reserved 2019 +
+ + ) +} diff --git a/src/components/Card/CardTable.tsx b/src/components/Card/CardTable.tsx index 08b2406..c6373f5 100644 --- a/src/components/Card/CardTable.tsx +++ b/src/components/Card/CardTable.tsx @@ -1,17 +1,17 @@ import React, { ReactNode } from "react" import { styled } from "../../style" -interface IColumn { +interface IColumn { title: string key: string align?: "left" | "center" | "right" - render?: (data: any) => ReactNode + render?: (data: T) => ReactNode } interface IProps { onRowClick?: (data: T) => any dataSource: T[] | undefined - columns: IColumn[] + columns: IColumn[] } const Table = styled.table` @@ -57,14 +57,14 @@ export default ({ columns, dataSource = [], onRowClick }: IProps) => { - {dataSource.map((data: any, index) => ( + {dataSource.map((data, index) => ( {columns.map(({ key, align, render }, index) => ( - {render ? render(data[key]) : data[key]} + {render ? render(data) : (data as any)[key]} ))} diff --git a/src/components/Container/ListContainer.tsx b/src/components/Container/ListContainer.tsx index 701200d..75936b0 100644 --- a/src/components/Container/ListContainer.tsx +++ b/src/components/Container/ListContainer.tsx @@ -34,9 +34,9 @@ const columns = [ { title: "Name", key: "name", - render: (name: any) => ( + render: (data: IContainer) => ( <> - {name} + {data.name} ), }, @@ -47,14 +47,14 @@ const columns = [ { title: "Created", key: "createdAt", - render: (createdAt: any) => , + render: (data: IContainer) => , }, { title: "Tags", key: "tags", - render: (tags: any) => ( + render: (data: IContainer) => ( <> - {tags.map((tag: any, i: number) => ( + {data.tags.map((tag: any, i: number) => ( {tag} ))} diff --git a/src/components/Image/ListImage.tsx b/src/components/Image/ListImage.tsx index 1f264fb..50299e6 100644 --- a/src/components/Image/ListImage.tsx +++ b/src/components/Image/ListImage.tsx @@ -1,15 +1,130 @@ import React from "react" -import { ButtonLink } from "../Form"; -import { Header } from "../Layout"; +import { Header } from "../Layout" +import { usePromise } from "../../hooks" +import { getImages, IImage } from "../../client" +import { Loader } from "../Loader" +import { Card, CardBody, CardTable } from "../Card" +import { Container } from "../Responsive" +import { styled } from "../../style" +import TimeAgo from "react-timeago" +import { Dropdown, DropdownButton, DropdownContent, DropdownItem } from "../Dropdown" +import { useMappedState } from "redux-react-hook" +const NoImage = styled(CardBody)` + h3 { + margin-bottom: 2rem; + } + + pre { + width: fit-content; + color: ${props => props.theme.color.light}; + background: ${props => props.theme.color.dark}; + padding: 0.5rem 1.25rem; + border-radius: 0.25rem; + font-size: 1rem; + + div { + padding: 0.5rem 0; + } + } +` + +const columns = [ + // { + // render: () => ( + // + // ), + // }, + { + title: "Name", + key: "name", + render: (name: any) => ( + <> + {name} + + ), + }, + { + title: "Image", + key: "image", + }, + { + title: "Created", + key: "createdAt", + render: (createdAt: any) => , + }, + { + title: "Tags", + key: "tags", + render: (tags: any) => ( + <> + + ), + }, + { + title: "", + key: "", + render: () => { + const overlay = () => ( + + Action + Another action + Something else here + + ) + + return ( + + + More + + + ) + }, + }, +] + +// columns={columns} dataSource={data}/> export default () => { + const { loading, data, error } = usePromise(() => getImages(), []) + const account = useMappedState(state => state.account.account) + return ( <> -
- - Create - -
+
+ + + {error && ( +

Error: {error.message}...

+ )} + + {data && ( + + {data.length ? ( +

Ok

+ ) : ( + +

Push your first image

+
+                    
+ docker login registry.expected.sh -u {account.email} -p {account.apiKey} +
+
+ docker push registry.expected.sh/{account.id}/{"<"}your image{">"} +
+
+
+ )} +
+ )} +
+
) } diff --git a/src/components/Layout/Navbar.tsx b/src/components/Layout/Navbar.tsx index 4358010..705f954 100644 --- a/src/components/Layout/Navbar.tsx +++ b/src/components/Layout/Navbar.tsx @@ -2,6 +2,7 @@ import React from "react" import { Link, Route } from "react-router-dom" import { styled } from "../../style" import { Container } from "../Responsive" +import { useMappedState } from "redux-react-hook" const Navbar = styled.div` background: ${props => props.theme.color.dark}; @@ -98,6 +99,8 @@ const Profile = styled.div` ` export default () => { + const account = useMappedState(state => state.account.account) + return ( @@ -107,8 +110,8 @@ export default () => { - Rémi Caumette - Avatar + {account.name} + Avatar diff --git a/src/index.tsx b/src/index.tsx index 906a150..7cddc5d 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -3,16 +3,22 @@ import ReactDOM from "react-dom" import { Router } from "react-router" import { createBrowserHistory } from "history" import { ThemeProvider } from "emotion-theming" +import { StoreContext } from "redux-react-hook" +import { createStore } from "redux" import { theme } from "./style" +import reducers from "./reducers" import App from "./components/App" import "./index.css" const history = createBrowserHistory() +const store = createStore(reducers) ReactDOM.render(( - - - - - + + + + + + + ), document.getElementById("root")) diff --git a/src/reducers/account.ts b/src/reducers/account.ts new file mode 100644 index 0000000..6837bff --- /dev/null +++ b/src/reducers/account.ts @@ -0,0 +1,24 @@ +import { IAccount } from "../client" + +type Action = + { type: "SET_ACCOUNT", account: IAccount } + +interface IState { + account: IAccount | undefined +} + +const INITIAL_STATE: IState = { + account: undefined, +} + +export default (state: IState = INITIAL_STATE, action: Action) => { + switch (action.type) { + case "SET_ACCOUNT": + return { + ...state, + account: action.account, + } + default: + return state + } +} diff --git a/src/reducers/index.ts b/src/reducers/index.ts new file mode 100644 index 0000000..6f96129 --- /dev/null +++ b/src/reducers/index.ts @@ -0,0 +1,4 @@ +import { combineReducers } from "redux" +import account from "./account" + +export default combineReducers({ account }) From 5e5fd681e0aa950193446cad932ecf588060a925 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Caumette?= Date: Tue, 16 Apr 2019 15:10:06 +0200 Subject: [PATCH 24/28] add navbar dropdown --- src/components/Dropdown/Dropdown.tsx | 8 ++++-- src/components/Dropdown/DropdownContent.tsx | 4 +-- src/components/Dropdown/DropdownItem.tsx | 8 ++++++ src/components/Layout/Navbar.tsx | 32 +++++++++++++++++---- 4 files changed, 42 insertions(+), 10 deletions(-) diff --git a/src/components/Dropdown/Dropdown.tsx b/src/components/Dropdown/Dropdown.tsx index ddc77c6..42bc20c 100644 --- a/src/components/Dropdown/Dropdown.tsx +++ b/src/components/Dropdown/Dropdown.tsx @@ -1,20 +1,22 @@ -import React, { ReactNode, useState } from "react" +import React, { CSSProperties, ReactNode, useState } from "react" import { styled } from "../../style" interface IProps { overlay: () => ReactNode children: ReactNode + style?: CSSProperties + className?: string } const Dropdown = styled.div` position: relative; ` -export default ({ overlay, children }: IProps) => { +export default ({ overlay, children, style = {}, className }: IProps) => { const [toggle, setToggle] = useState(false) return ( - setToggle(false)}> + setToggle(false)} style={style} className={className}>
setToggle(!toggle)}> {children}
diff --git a/src/components/Dropdown/DropdownContent.tsx b/src/components/Dropdown/DropdownContent.tsx index 4001895..b514e97 100644 --- a/src/components/Dropdown/DropdownContent.tsx +++ b/src/components/Dropdown/DropdownContent.tsx @@ -3,8 +3,8 @@ import { styled } from "../../style" export default styled.div` position: absolute; will-change: transform; - top: -10px; - left: -10px; + top: 0; + left: 0; transform: translate3d(0, 38px, 0); border: 1px solid #edf2f9; box-shadow: 0 0.75rem 1.5rem rgba(18, 38, 63, 0.03); diff --git a/src/components/Dropdown/DropdownItem.tsx b/src/components/Dropdown/DropdownItem.tsx index 8ed1db3..a80d684 100644 --- a/src/components/Dropdown/DropdownItem.tsx +++ b/src/components/Dropdown/DropdownItem.tsx @@ -11,4 +11,12 @@ export default styled.div` white-space: nowrap; background-color: transparent; border: 0; + + a:hover { + text-decoration: none; + } + + &:hover { + background: ${props => props.theme.color.greyLight}; + } ` diff --git a/src/components/Layout/Navbar.tsx b/src/components/Layout/Navbar.tsx index 705f954..02cbfdb 100644 --- a/src/components/Layout/Navbar.tsx +++ b/src/components/Layout/Navbar.tsx @@ -3,6 +3,7 @@ import { Link, Route } from "react-router-dom" import { styled } from "../../style" import { Container } from "../Responsive" import { useMappedState } from "redux-react-hook" +import { Dropdown, DropdownButton, DropdownContent, DropdownItem } from "../Dropdown" const Navbar = styled.div` background: ${props => props.theme.color.dark}; @@ -87,8 +88,11 @@ const NavLink = ({ to, exact, name }: INavLinkProps) => { ) } -const Profile = styled.div` +const ProfileDropdown = styled(Dropdown)` align-self: center; +` + +const Profile = styled(DropdownButton.withComponent("div"))` color: rgba(255, 255, 255, 0.5); img { @@ -96,11 +100,27 @@ const Profile = styled.div` height: 32px; border-radius: 5px; } + + &::after { + margin-left: .4em; + vertical-align: 0; + } ` export default () => { const account = useMappedState(state => state.account.account) + const overlay = () => ( + + + Account + + + Billing + + + ) + return ( @@ -109,10 +129,12 @@ export default () => { - - {account.name} - Avatar - + + + {account.name} + Avatar + + ) From 79f98b364e939f504bd15978dbd9c4fe1e6c44b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Caumette?= Date: Wed, 17 Apr 2019 16:57:32 +0200 Subject: [PATCH 25/28] update new container --- src/client.ts | 25 ++--- src/components/Account/Account.tsx | 98 +++++++++--------- src/components/App.tsx | 2 +- src/components/Card/CardTable.tsx | 19 ++-- src/components/Container/ListContainer.tsx | 15 --- src/components/Container/NewContainer.tsx | 109 ++++++++++++++------- src/components/Container/Plan.tsx | 57 +++++------ src/components/Image/ListImage.tsx | 44 +++------ src/components/Layout/Navbar.tsx | 8 +- 9 files changed, 178 insertions(+), 199 deletions(-) diff --git a/src/client.ts b/src/client.ts index 212c62a..7b3a951 100644 --- a/src/client.ts +++ b/src/client.ts @@ -37,14 +37,11 @@ export interface CreateContainerRequest { tags: string[] } -export interface IImage { - id: string +export interface ImageSummary { name: string tag: string - layers: number - size: number - digest: string - createdAt: Date + namespaceId: string + lastPush: Date } const remapFields = (obj: any, fields: any): any => @@ -62,14 +59,11 @@ const toAccount = (data: object): IAccount => { return account } -const toImage = (data: object): IImage => { - const image = remapFields(data, { - image_id: "id", - created_at: "createdAt", +const toImageSummary = (data: object): ImageSummary => + remapFields(data, { + namespace_id: "namespaceId", + last_push: "lastPush", }) - image.createdAt = new Date(image.createdAt) - return image -} const toContainer = (data: object): IContainer => { const container = remapFields(data, { @@ -140,11 +134,10 @@ export const getContainerPlans = (): Promise => return res.data.plans }) -export const getImages = (): Promise => +export const getImages = (): Promise => client.get("/v1/images").then((res) => { if (res.status !== 200) { throw new Error(res.data.message) } - console.log(res.data.images) - return res.data.images.map((data: object) => toImage(data)) + return res.data.images.map((data: object) => toImageSummary(data)) }) diff --git a/src/components/Account/Account.tsx b/src/components/Account/Account.tsx index 56b96c4..d1f5418 100644 --- a/src/components/Account/Account.tsx +++ b/src/components/Account/Account.tsx @@ -1,88 +1,78 @@ import React, { useState } from "react" -import { getAccount, regenerateApiKey, syncAccount } from "../../client" +import { regenerateApiKey, syncAccount } from "../../client" import { Header } from "../Layout" import { Button, FormGroup, FormSection, Input } from "../Form" -import { usePromise } from "../../hooks" import { Col, Container, Row } from "../Responsive" -import { Loader } from "../Loader" +import { useDispatch, useMappedState } from "redux-react-hook" export default () => { - const { loading, data, error, dispatch } = usePromise(() => getAccount(), []) + const account = useMappedState(state => state.account.account) + const dispatch = useDispatch() const [reveal, setReveal] = useState(false) const [apiError, setApiError] = useState() const regenerateApiKeyHandler = () => { regenerateApiKey() .then((data) => { - dispatch({ action: "SET_DATA", data }) + dispatch({ type: "SET_ACCOUNT", account: data }) document.cookie = `token=${data.apiKey}` setReveal(true) setApiError(undefined) }) - .catch((error) => { - setApiError(error) - }) + .catch((error) => setApiError(error)) } const syncAccountHandler = () => { syncAccount() .then((data) => { - dispatch({ action: "SET_DATA", data }) + dispatch({ type: "SET_ACCOUNT", account: data }) setApiError(undefined) }) - .catch((error) => { - setApiError(error) - }) + .catch((error) => setApiError(error)) } -// form-row form-group + return ( <>
- {error && ( -

Error: {error.message}...

+ {apiError && ( +
+ {apiError.message} +
)} - - {apiError && ( -
- {apiError.message} -
- )} - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + - - -
+ + + + + +
) diff --git a/src/components/App.tsx b/src/components/App.tsx index ba1dd37..c65e1b2 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -15,7 +15,7 @@ export default () => { if (data) { dispatch({ type: "SET_ACCOUNT", account: data }) } else if (error) { - window.location.href = process.env.AUTH_URL || "http://localhost:3002" + window.location.href = process.env.AUTH_URL || "http://localhost:3002/oauth/github" } return loading ? ( diff --git a/src/components/Card/CardTable.tsx b/src/components/Card/CardTable.tsx index c6373f5..26fe8cc 100644 --- a/src/components/Card/CardTable.tsx +++ b/src/components/Card/CardTable.tsx @@ -1,12 +1,13 @@ import React, { ReactNode } from "react" import { styled } from "../../style" -interface IColumn { - title: string - key: string - align?: "left" | "center" | "right" - render?: (data: T) => ReactNode -} +type IColumn = + { + title: string + align?: "left" | "center" | "right" + key?: string + render?: (data: T) => ReactNode + } interface IProps { onRowClick?: (data: T) => any @@ -59,12 +60,12 @@ export default ({ columns, dataSource = [], onRowClick }: IProps) => { {dataSource.map((data, index) => ( - {columns.map(({ key, align, render }, index) => ( + {columns.map((column, index) => ( - {render ? render(data) : (data as any)[key]} + {column.render ? column.render(data) : (data as any)[column.key as any]} ))} diff --git a/src/components/Container/ListContainer.tsx b/src/components/Container/ListContainer.tsx index 75936b0..81df36b 100644 --- a/src/components/Container/ListContainer.tsx +++ b/src/components/Container/ListContainer.tsx @@ -20,20 +20,8 @@ const Tag = styled.div` ` const columns = [ - // { - // render: () => ( - // - // ), - // }, { title: "Name", - key: "name", render: (data: IContainer) => ( <> {data.name} @@ -46,12 +34,10 @@ const columns = [ }, { title: "Created", - key: "createdAt", render: (data: IContainer) => , }, { title: "Tags", - key: "tags", render: (data: IContainer) => ( <> {data.tags.map((tag: any, i: number) => ( @@ -62,7 +48,6 @@ const columns = [ }, { title: "", - key: "", render: () => { const overlay = () => ( diff --git a/src/components/Container/NewContainer.tsx b/src/components/Container/NewContainer.tsx index 7b408ad..2d3c9ed 100644 --- a/src/components/Container/NewContainer.tsx +++ b/src/components/Container/NewContainer.tsx @@ -1,51 +1,87 @@ -import React from "react" +import React, { FormEvent, useEffect, useReducer } from "react" import { Header } from "../Layout" -import { AutocompleteInput, Button, Form, FormGroup, FormSection, Input, Select, TagInput } from "../Form" +import { AutocompleteInput, Button, Form, FormGroup, FormSection, Input, TagInput } from "../Form" import { Col, Container, Row } from "../Responsive" -import { useForm, usePromise } from "../../hooks" -import { getContainerPlans } from "../../client" -import { styled } from "../../style" +import { Plan, PlanTable } from "./Plan" +import { createContainer, CreateContainerRequest, getContainerPlans, IContainerPlan } from "../../client" -const test = () => new Promise((resolve, reject) => setTimeout(resolve, 4000)) +type Action = + { type: "SET_LOADING", loading: boolean } | + { type: "SET_FORM_VALUE", key: string, value: any } | + { type: "SET_PLANS", plans: IContainerPlan[] } -const PlanTable = styled.table` - width: 100%; -` +interface IState { + loading: boolean + form: CreateContainerRequest, + plans: IContainerPlan[] +} -const Plan = styled.tr` - border: 1px solid ${props => props.theme.color.grey}; - border-radius: 0.25rem; - - td { - padding: 0.5rem 1rem; +const reducer = (state: IState, action: Action) => { + switch (action.type) { + case "SET_LOADING": + return { + ...state, + loading: action.loading, + } + case "SET_FORM_VALUE": + return { + ...state, + form: { + ...state.form, + [action.key]: action.value, + }, + } + case "SET_PLANS": + return { + ...state, + plans: action.plans, + } + default: + return state } -` +} export default () => { - const { data, loading: load } = usePromise(() => Promise.all([ - getContainerPlans(), - ]), []) - - const { loading, error, handleChange, handleSubmit, dispatch, values } = useForm({ - name: "", - image: "", - size: "", - tags: "", - }, async (values) => { - // await test() + const [state, dispatch] = useReducer(reducer, { + loading: true, + form: { + name: "", + image: "", + size: "64", + tags: [], + }, + plans: [], }) + useEffect(() => { + getContainerPlans() + .then(plans => dispatch({ type: "SET_PLANS", plans })) + .finally(() => dispatch({ type: "SET_LOADING", loading: false })) + // .catch(error => ) + }, []) + + const handleSubmit = (event: FormEvent) => { + event.preventDefault() + + dispatch({ type: "SET_LOADING", loading: true }) + createContainer(state.form) + .then(console.log) + .catch(console.error) + .finally(() => dispatch({ type: "SET_LOADING", loading: false })) + } + return ( <>
- + + onChange={(event) => dispatch({ type: "SET_FORM_VALUE", key: "name", value: event.target.value })} + autoComplete="off"/> @@ -55,14 +91,17 @@ export default () => { "hello world", "hai!", "Super", "Supra!", "Sjikl", ]} - onChange={(a) => console.log("image:", a.target.value)}/> + onChange={(event) => dispatch({ + type: "SET_FORM_VALUE", + key: "image", + value: event.target.value, + })}/> - console.log("tags:", a)}/> + dispatch({ type: "SET_FORM_VALUE", key: "tags", value: tags })}/> @@ -70,7 +109,7 @@ export default () => { description=" do eiusmod tempor incididunt ut labore et dolore magna aliqua."> - {data && data[0].map((plan, index) => ( + {state.plans && state.plans.map((plan, index) => ( {plan.name} {plan.cpu} vCPU @@ -84,7 +123,7 @@ export default () => { - diff --git a/src/components/Container/Plan.tsx b/src/components/Container/Plan.tsx index 738699a..350cf5c 100644 --- a/src/components/Container/Plan.tsx +++ b/src/components/Container/Plan.tsx @@ -1,5 +1,4 @@ import React from "react" -import { Col } from "../Responsive" import { styled } from "../../style" interface IProps { @@ -8,41 +7,29 @@ interface IProps { memory: number } -const Plan = styled.div` - background: ${props => props.theme.color.light}; +export const PlanTable = styled.table` + width: 100%; +` + +export const Plan = styled.tr` border: 1px solid ${props => props.theme.color.grey}; - border-radius: 5px; - padding: 1rem; - width: 250px; - - h3 { - text-align: center; - margin-bottom: 1.2rem; - } - - ul { - list-style: none; - padding: 0; - - li { - border-top: 1px solid ${props => props.theme.color.grey}; - padding: 0.4rem 0.8rem; - } + border-radius: 0.25rem; + + td { + padding: 0.5rem 1rem; } ` -export default ({ name, cpu, memory }: IProps) => { - return ( - <> - -

{name}

- -
    -
  • {cpu} virtual cpu
  • -
  • {memory}MB
  • -
  • Unlimited bandwidth
  • -
-
- - ) -} +// export const Plan = ({ name, cpu, memory }: IProps) => { +// return ( +// +//

{name}

+// +//
    +//
  • {cpu} virtual cpu
  • +//
  • {memory}MB
  • +//
  • Unlimited bandwidth
  • +//
+//
+// ) +// } diff --git a/src/components/Image/ListImage.tsx b/src/components/Image/ListImage.tsx index 50299e6..7944443 100644 --- a/src/components/Image/ListImage.tsx +++ b/src/components/Image/ListImage.tsx @@ -1,12 +1,12 @@ import React from "react" +import TimeAgo from "react-timeago" import { Header } from "../Layout" import { usePromise } from "../../hooks" -import { getImages, IImage } from "../../client" +import { getImages, ImageSummary } from "../../client" import { Loader } from "../Loader" import { Card, CardBody, CardTable } from "../Card" import { Container } from "../Responsive" import { styled } from "../../style" -import TimeAgo from "react-timeago" import { Dropdown, DropdownButton, DropdownContent, DropdownItem } from "../Dropdown" import { useMappedState } from "redux-react-hook" @@ -30,46 +30,28 @@ const NoImage = styled(CardBody)` ` const columns = [ - // { - // render: () => ( - // - // ), - // }, { title: "Name", - key: "name", - render: (name: any) => ( + render: (data: ImageSummary) => ( <> - {name} + {data.name}:{data.tag} ), }, { - title: "Image", - key: "image", - }, - { - title: "Created", - key: "createdAt", - render: (createdAt: any) => , - }, - { - title: "Tags", - key: "tags", - render: (tags: any) => ( + title: "URL", + render: (data: ImageSummary) => ( <> + registry.expected.sh/{data.namespaceId}/{data.name}:{data.tag} ), }, + { + title: "Last push", + render: (data: ImageSummary) => , + }, { title: "", - key: "", render: () => { const overlay = () => ( @@ -90,7 +72,6 @@ const columns = [ }, ] -// columns={columns} dataSource={data}/> export default () => { const { loading, data, error } = usePromise(() => getImages(), []) const account = useMappedState(state => state.account.account) @@ -107,7 +88,8 @@ export default () => { {data && ( {data.length ? ( -

Ok

+ columns={columns} dataSource={data} + onRowClick={(data) => console.log(data)}/> ) : (

Push your first image

diff --git a/src/components/Layout/Navbar.tsx b/src/components/Layout/Navbar.tsx index 02cbfdb..7a6aa59 100644 --- a/src/components/Layout/Navbar.tsx +++ b/src/components/Layout/Navbar.tsx @@ -93,12 +93,14 @@ const ProfileDropdown = styled(Dropdown)` ` const Profile = styled(DropdownButton.withComponent("div"))` + cursor: pointer; color: rgba(255, 255, 255, 0.5); + user-select: none; img { - margin-left: 15px; + margin-left: 1rem; height: 32px; - border-radius: 5px; + border-radius: 0.25rem; } &::after { @@ -111,7 +113,7 @@ export default () => { const account = useMappedState(state => state.account.account) const overlay = () => ( - + Account From 29fad6298a1b2d654ac5904c2efdc87ba16d04da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Caumette?= Date: Wed, 17 Apr 2019 22:15:48 +0200 Subject: [PATCH 26/28] update client --- src/client.ts | 143 --------------------- src/client/account.ts | 52 ++++++++ src/client/container.ts | 53 ++++++++ src/client/image.ts | 26 ++++ src/client/index.ts | 29 +++++ src/client/plan.ts | 1 + src/components/Account/Account.tsx | 10 +- src/components/App.tsx | 9 +- src/components/Container/ListContainer.tsx | 19 ++- src/components/Container/NewContainer.tsx | 51 +++++--- src/components/Form/AutocompleteInput.tsx | 20 +-- src/components/Form/Form.tsx | 2 +- src/components/Image/ListImage.tsx | 17 ++- src/reducers/account.ts | 6 +- 14 files changed, 241 insertions(+), 197 deletions(-) delete mode 100644 src/client.ts create mode 100644 src/client/account.ts create mode 100644 src/client/container.ts create mode 100644 src/client/image.ts create mode 100644 src/client/index.ts create mode 100644 src/client/plan.ts diff --git a/src/client.ts b/src/client.ts deleted file mode 100644 index 7b3a951..0000000 --- a/src/client.ts +++ /dev/null @@ -1,143 +0,0 @@ -import axios from "axios" - -export interface IAccount { - id: string - name: string - email: string - avatarUrl: string - apiKey: string - admin: boolean - createdAt: Date -} - -export interface IContainer { - id: string - name: string - status: string - image: string - endpoint: string - memory: number - tags: string[] - createdAt: Date -} - -export interface IContainerPlan { - id: string - name: string - price: number - cpu: number - memory: number - available: boolean -} - -export interface CreateContainerRequest { - name: string - image: string - size: string - tags: string[] -} - -export interface ImageSummary { - name: string - tag: string - namespaceId: string - lastPush: Date -} - -const remapFields = (obj: any, fields: any): any => - Object.entries(obj) - .map(([key, value]) => [fields[key] || key, value]) - .reduce((prev, [key, value]) => ({ ...prev, [key]: value }), {}) - -const toAccount = (data: object): IAccount => { - const account = remapFields(data, { - avatar_url: "avatarUrl", - api_key: "apiKey", - created_at: "createdAt", - }) - account.createdAt = new Date(account.createdAt) - return account -} - -const toImageSummary = (data: object): ImageSummary => - remapFields(data, { - namespace_id: "namespaceId", - last_push: "lastPush", - }) - -const toContainer = (data: object): IContainer => { - const container = remapFields(data, { - created_at: "createdAt", - }) - container.createdAt = new Date(container.createdAt) - return container -} - -export const client = axios.create({ - baseURL: process.env.API_URL || "http://localhost:3000", - transformRequest(data, headers) { - headers.Authorization = document.cookie.split("=")[1] - return data - }, - validateStatus() { - return true - }, -}) - -export const getAccount = (): Promise => - client.get("/v1/account").then((res) => { - if (res.status !== 200) { - throw new Error(res.data.message) - } - return toAccount(res.data.account) - }) - -export const syncAccount = (): Promise => - client.post("/v1/account/sync").then((res) => { - if (res.status !== 200) { - throw new Error(res.data.message) - } - return toAccount(res.data.account) - }) - -export const regenerateApiKey = (): Promise => - client.post("/v1/account/regenerate_apikey").then((res) => { - if (res.status !== 200) { - throw new Error(res.data.message) - } - return toAccount(res.data.account) - }) - -export const getContainers = (): Promise => - client.get("/v1/containers").then((res) => { - if (res.status !== 200) { - throw new Error(res.data.message) - } - return res.data.containers.map((data: object) => toContainer(data)) - }) - -export const createContainer = (req: CreateContainerRequest): Promise => - client.post("/v1/containers", req) - .then((res) => { - if (res.status !== 200) { - throw new Error(res.data.message) - } - return toContainer(res.data.container) - }) - -export const getContainerPlans = (): Promise => - client.get("/v1/containers/plans") - .then((res) => { - if (res.status !== 200) { - throw new Error(res.data.message) - } - return res.data.plans - }) - -export const getImages = (): Promise => - client.get("/v1/images").then((res) => { - if (res.status !== 200) { - throw new Error(res.data.message) - } - return res.data.images.map((data: object) => toImageSummary(data)) - }) diff --git a/src/client/account.ts b/src/client/account.ts new file mode 100644 index 0000000..9ba14c8 --- /dev/null +++ b/src/client/account.ts @@ -0,0 +1,52 @@ +import { ErrorResponse, remapFields, client } from "./index" + +export interface Account { + id: string + name: string + email: string + avatarUrl: string + apiKey: string + admin: boolean + createdAt: Date +} + +export interface AccountResponse { + account: Account +} + +const toAccount = (data: object): Account => { + const account = remapFields(data, { + avatar_url: "avatarUrl", + api_key: "apiKey", + created_at: "createdAt", + }) + account.createdAt = new Date(account.createdAt) + return account +} + +export const getAccount = (): Promise => + client.get("/v1/account") + .then((res) => { + if (res.status !== 200) { + return res.data + } + return { account: toAccount(res.data.account) } + }) + +export const syncAccount = (): Promise => + client.post("/v1/account/sync") + .then((res) => { + if (res.status !== 200) { + return res.data + } + return { account: toAccount(res.data.account) } + }) + +export const regenerateApiKey = (): Promise => + client.post("/v1/account/regenerate_apikey") + .then((res) => { + if (res.status !== 200) { + return res.data + } + return { account: toAccount(res.data.account) } + }) diff --git a/src/client/container.ts b/src/client/container.ts new file mode 100644 index 0000000..4bb0f5e --- /dev/null +++ b/src/client/container.ts @@ -0,0 +1,53 @@ +import { client, ErrorResponse, remapFields } from "./index" + +export interface Container { + id: string + name: string + status: string + image: string + endpoint: string + memory: number + tags: string[] + createdAt: Date +} + +const toContainer = (data: object): Container => { + const container = remapFields(data, { + created_at: "createdAt", + }) + container.createdAt = new Date(container.createdAt) + return container +} + +export interface ContainerResponse { + container: Container +} + +export interface ListContainerResponse { + containers: Container[] +} + +export const getContainers = (): Promise => + client.get("/v1/containers") + .then((res) => { + if (res.status !== 200) { + return res.data + } + return { containers: res.data.containers.map((data: object) => toContainer(data)) } + }) + +export interface CreateContainerRequest { + name: string + image: string + size: string + tags: string[] +} + +export const createContainer = (req: CreateContainerRequest): Promise => + client.post("/v1/containers", req) + .then((res) => { + if (res.status !== 200) { + return res.data + } + return { container: res.data.container } + }) diff --git a/src/client/image.ts b/src/client/image.ts new file mode 100644 index 0000000..d299eb8 --- /dev/null +++ b/src/client/image.ts @@ -0,0 +1,26 @@ +import { client, ErrorResponse, remapFields } from "./index" + +export interface ImageSummary { + name: string + tag: string + namespaceId: string + lastPush: Date +} + +const toImageSummary = (data: object): ImageSummary => + remapFields(data, { + namespace_id: "namespaceId", + last_push: "lastPush", + }) + +export interface ListImageResponse { + images: ImageSummary[] +} + +export const getImages = (): Promise => + client.get("/v1/images").then((res) => { + if (res.status !== 200) { + return res.data + } + return { images: res.data.images.map((data: object) => toImageSummary(data)) } + }) diff --git a/src/client/index.ts b/src/client/index.ts new file mode 100644 index 0000000..70e8ca1 --- /dev/null +++ b/src/client/index.ts @@ -0,0 +1,29 @@ +import axios from "axios" +import * as container from "./container" +import * as account from "./account" +import * as image from "./image" + +export const client = axios.create({ + baseURL: process.env.API_URL || "http://localhost:3000", + transformRequest(data, headers) { + headers.Authorization = document.cookie.split("=")[1] + return data + }, + validateStatus() { + return true + }, +}) + +export const remapFields = (obj: any, fields: any): any => + Object.entries(obj) + .map(([key, value]) => [fields[key] || key, value]) + .reduce((prev, [key, value]) => ({ ...prev, [key]: value }), {}) + +export interface ErrorResponse { + message: string + errors?: object +} + +export default { ...account } + +export { container, account, image } diff --git a/src/client/plan.ts b/src/client/plan.ts new file mode 100644 index 0000000..ead516c --- /dev/null +++ b/src/client/plan.ts @@ -0,0 +1 @@ +export default () => {} diff --git a/src/components/Account/Account.tsx b/src/components/Account/Account.tsx index d1f5418..a198b27 100644 --- a/src/components/Account/Account.tsx +++ b/src/components/Account/Account.tsx @@ -1,18 +1,18 @@ import React, { useState } from "react" -import { regenerateApiKey, syncAccount } from "../../client" import { Header } from "../Layout" import { Button, FormGroup, FormSection, Input } from "../Form" import { Col, Container, Row } from "../Responsive" import { useDispatch, useMappedState } from "redux-react-hook" +import client from "../../client" export default () => { const account = useMappedState(state => state.account.account) const dispatch = useDispatch() const [reveal, setReveal] = useState(false) - const [apiError, setApiError] = useState() + const [apiError, setApiError] = useState() const regenerateApiKeyHandler = () => { - regenerateApiKey() + client.regenerateApiKey() .then((data) => { dispatch({ type: "SET_ACCOUNT", account: data }) document.cookie = `token=${data.apiKey}` @@ -23,7 +23,7 @@ export default () => { } const syncAccountHandler = () => { - syncAccount() + client.syncAccount() .then((data) => { dispatch({ type: "SET_ACCOUNT", account: data }) setApiError(undefined) @@ -38,7 +38,7 @@ export default () => { {apiError && (
- {apiError.message} + {apiError}
)} { - const { data, loading, error } = usePromise(() => getAccount(), []) + const { data, loading, error } = usePromise(async () => { + const res = await account.getAccount() as account.AccountResponse + if (res.account) { + return res.account + } + }, []) const dispatch = useDispatch() if (data) { diff --git a/src/components/Container/ListContainer.tsx b/src/components/Container/ListContainer.tsx index 81df36b..99b4244 100644 --- a/src/components/Container/ListContainer.tsx +++ b/src/components/Container/ListContainer.tsx @@ -1,7 +1,7 @@ import React from "react" import TimeAgo from "react-timeago" +import { container } from "../../client" import { usePromise } from "../../hooks" -import { getContainers, IContainer } from "../../client" import { Header } from "../Layout" import { Container } from "../Responsive" import { Card, CardTable } from "../Card" @@ -22,7 +22,7 @@ const Tag = styled.div` const columns = [ { title: "Name", - render: (data: IContainer) => ( + render: (data: container.Container) => ( <> {data.name} @@ -34,11 +34,11 @@ const columns = [ }, { title: "Created", - render: (data: IContainer) => , + render: (data: container.Container) => , }, { title: "Tags", - render: (data: IContainer) => ( + render: (data: container.Container) => ( <> {data.tags.map((tag: any, i: number) => ( {tag} @@ -69,7 +69,12 @@ const columns = [ ] export default () => { - const { loading, data, error } = usePromise(() => getContainers(), []) + const { loading, data, error } = usePromise(async () => { + const res = await container.getContainers() as container.ListContainerResponse + if (res.containers) { + return res.containers + } + }, []) return ( <> @@ -86,8 +91,8 @@ export default () => { {data && ( - columns={columns} dataSource={data} - onRowClick={(data) => console.log(data)}/> + columns={columns} dataSource={data} + onRowClick={(data) => console.log(data)}/> )} diff --git a/src/components/Container/NewContainer.tsx b/src/components/Container/NewContainer.tsx index 2d3c9ed..77ed7d2 100644 --- a/src/components/Container/NewContainer.tsx +++ b/src/components/Container/NewContainer.tsx @@ -3,7 +3,9 @@ import { Header } from "../Layout" import { AutocompleteInput, Button, Form, FormGroup, FormSection, Input, TagInput } from "../Form" import { Col, Container, Row } from "../Responsive" import { Plan, PlanTable } from "./Plan" -import { createContainer, CreateContainerRequest, getContainerPlans, IContainerPlan } from "../../client" +import { container } from "../../client" + +interface IContainerPlan {} type Action = { type: "SET_LOADING", loading: boolean } | @@ -12,8 +14,14 @@ type Action = interface IState { loading: boolean - form: CreateContainerRequest, + form: container.CreateContainerRequest plans: IContainerPlan[] + error?: string + formError?: { + name: string + image: string + tags: string + } } const reducer = (state: IState, action: Action) => { @@ -53,18 +61,18 @@ export default () => { plans: [], }) - useEffect(() => { - getContainerPlans() - .then(plans => dispatch({ type: "SET_PLANS", plans })) - .finally(() => dispatch({ type: "SET_LOADING", loading: false })) - // .catch(error => ) - }, []) + // useEffect(() => { + // container.() + // .then(plans => dispatch({ type: "SET_PLANS", plans })) + // .finally(() => dispatch({ type: "SET_LOADING", loading: false })) + // // .catch(error => ) + // }, []) const handleSubmit = (event: FormEvent) => { event.preventDefault() dispatch({ type: "SET_LOADING", loading: true }) - createContainer(state.form) + container.createContainer(state.form) .then(console.log) .catch(console.error) .finally(() => dispatch({ type: "SET_LOADING", loading: false })) @@ -76,15 +84,16 @@ export default () => { + {state.error &&

{state.error}

} - + dispatch({ type: "SET_FORM_VALUE", key: "name", value: event.target.value })} autoComplete="off"/> - + { })}/> - dispatch({ type: "SET_FORM_VALUE", key: "tags", value: tags })}/> @@ -109,14 +118,7 @@ export default () => { description=" do eiusmod tempor incididunt ut labore et dolore magna aliqua."> - {state.plans && state.plans.map((plan, index) => ( - - {plan.name} - {plan.cpu} vCPU - {plan.memory}MB - ${plan.price} - - ))} + @@ -133,3 +135,12 @@ export default () => { ) } + +// {state.plans && state.plans.map((plan, index) => ( +// +// {plan.name} +// {plan.cpu} vCPU +// {plan.memory}MB +// ${plan.price} +// +// ))} diff --git a/src/components/Form/AutocompleteInput.tsx b/src/components/Form/AutocompleteInput.tsx index 0a480af..714d4d9 100644 --- a/src/components/Form/AutocompleteInput.tsx +++ b/src/components/Form/AutocompleteInput.tsx @@ -45,16 +45,16 @@ export default ({ name, onChange, suggestions, placeholder = "", suggestionRende const [id] = useState('_' + Math.random().toString(36).substr(2, 9)) useEffect(() => { - const focusOut = (event: any) => { - hideCompletion() - event.preventDefault() - } - document.addEventListener("click", focusOut) - window.addEventListener("resize", focusOut) - return () => { - document.removeEventListener("click", focusOut) - window.removeEventListener("resize", focusOut) - } + // const focusOut = (event: any) => { + // hideCompletion() + // event.preventDefault() + // } + // document.addEventListener("click", focusOut) + // window.addEventListener("resize", focusOut) + // return () => { + // document.removeEventListener("click", focusOut) + // window.removeEventListener("resize", focusOut) + // } }) diff --git a/src/components/Form/Form.tsx b/src/components/Form/Form.tsx index 80ff820..43f7995 100644 --- a/src/components/Form/Form.tsx +++ b/src/components/Form/Form.tsx @@ -3,7 +3,7 @@ import { styled } from "../../style" import { Loader } from "../Loader" interface IProps { - onSubmit?: (event: FormEvent) => void + onSubmit?: (event: FormEvent) => any loading?: boolean children: ReactNode } diff --git a/src/components/Image/ListImage.tsx b/src/components/Image/ListImage.tsx index 7944443..51dd171 100644 --- a/src/components/Image/ListImage.tsx +++ b/src/components/Image/ListImage.tsx @@ -2,13 +2,13 @@ import React from "react" import TimeAgo from "react-timeago" import { Header } from "../Layout" import { usePromise } from "../../hooks" -import { getImages, ImageSummary } from "../../client" import { Loader } from "../Loader" import { Card, CardBody, CardTable } from "../Card" import { Container } from "../Responsive" import { styled } from "../../style" import { Dropdown, DropdownButton, DropdownContent, DropdownItem } from "../Dropdown" import { useMappedState } from "redux-react-hook" +import { image } from "../../client" const NoImage = styled(CardBody)` h3 { @@ -32,7 +32,7 @@ const NoImage = styled(CardBody)` const columns = [ { title: "Name", - render: (data: ImageSummary) => ( + render: (data: image.ImageSummary) => ( <> {data.name}:{data.tag} @@ -40,7 +40,7 @@ const columns = [ }, { title: "URL", - render: (data: ImageSummary) => ( + render: (data: image.ImageSummary) => ( <> registry.expected.sh/{data.namespaceId}/{data.name}:{data.tag} @@ -48,7 +48,7 @@ const columns = [ }, { title: "Last push", - render: (data: ImageSummary) => , + render: (data: image.ImageSummary) => , }, { title: "", @@ -73,7 +73,12 @@ const columns = [ ] export default () => { - const { loading, data, error } = usePromise(() => getImages(), []) + const { loading, data, error } = usePromise(async () => { + const res = await image.getImages() as image.ListImageResponse + if (res.images) { + return res.images + } + }, []) const account = useMappedState(state => state.account.account) return ( @@ -88,7 +93,7 @@ export default () => { {data && ( {data.length ? ( - columns={columns} dataSource={data} + columns={columns} dataSource={data} onRowClick={(data) => console.log(data)}/> ) : ( diff --git a/src/reducers/account.ts b/src/reducers/account.ts index 6837bff..0b0eb95 100644 --- a/src/reducers/account.ts +++ b/src/reducers/account.ts @@ -1,10 +1,10 @@ -import { IAccount } from "../client" +import { account } from "../client" type Action = - { type: "SET_ACCOUNT", account: IAccount } + { type: "SET_ACCOUNT", account: account.Account } interface IState { - account: IAccount | undefined + account: account.Account | undefined } const INITIAL_STATE: IState = { From 0db4eb59b74bbc308d2b0e15753e3d3530f30f90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Caumette?= Date: Thu, 18 Apr 2019 11:19:34 +0200 Subject: [PATCH 27/28] convert to api response --- src/client/account.ts | 24 ++++----- src/client/container.ts | 22 +++----- src/client/image.ts | 12 ++--- src/client/index.ts | 11 ++-- src/components/Account/Account.tsx | 84 +++++++++++++++++++++++------- src/components/App.tsx | 68 ++++++++++++++++++------ 6 files changed, 146 insertions(+), 75 deletions(-) diff --git a/src/client/account.ts b/src/client/account.ts index 9ba14c8..41fe889 100644 --- a/src/client/account.ts +++ b/src/client/account.ts @@ -1,4 +1,4 @@ -import { ErrorResponse, remapFields, client } from "./index" +import { ApiResponse, client, remapFields } from "./index" export interface Account { id: string @@ -10,10 +10,6 @@ export interface Account { createdAt: Date } -export interface AccountResponse { - account: Account -} - const toAccount = (data: object): Account => { const account = remapFields(data, { avatar_url: "avatarUrl", @@ -24,29 +20,29 @@ const toAccount = (data: object): Account => { return account } -export const getAccount = (): Promise => +export const getAccount = (): Promise> => client.get("/v1/account") .then((res) => { if (res.status !== 200) { - return res.data + return { status: res.status, error: res.data } } - return { account: toAccount(res.data.account) } + return { status: res.status, data: toAccount(res.data.account) } }) -export const syncAccount = (): Promise => +export const syncAccount = (): Promise> => client.post("/v1/account/sync") .then((res) => { if (res.status !== 200) { - return res.data + return { status: res.status, error: res.data } } - return { account: toAccount(res.data.account) } + return { status: res.status, data: toAccount(res.data.account) } }) -export const regenerateApiKey = (): Promise => +export const regenerateApiKey = (): Promise> => client.post("/v1/account/regenerate_apikey") .then((res) => { if (res.status !== 200) { - return res.data + return { status: res.status, error: res.data } } - return { account: toAccount(res.data.account) } + return { status: res.status, data: toAccount(res.data.account) } }) diff --git a/src/client/container.ts b/src/client/container.ts index 4bb0f5e..d25fb5d 100644 --- a/src/client/container.ts +++ b/src/client/container.ts @@ -1,4 +1,4 @@ -import { client, ErrorResponse, remapFields } from "./index" +import { ApiResponse, client, remapFields } from "./index" export interface Container { id: string @@ -19,21 +19,13 @@ const toContainer = (data: object): Container => { return container } -export interface ContainerResponse { - container: Container -} - -export interface ListContainerResponse { - containers: Container[] -} - -export const getContainers = (): Promise => +export const getContainers = (): Promise> => client.get("/v1/containers") .then((res) => { if (res.status !== 200) { - return res.data + return { status: res.status, error: res.data } } - return { containers: res.data.containers.map((data: object) => toContainer(data)) } + return { status: res.status, data: res.data.containers.map((data: object) => toContainer(data)) } }) export interface CreateContainerRequest { @@ -43,11 +35,11 @@ export interface CreateContainerRequest { tags: string[] } -export const createContainer = (req: CreateContainerRequest): Promise => +export const createContainer = (req: CreateContainerRequest): Promise> => client.post("/v1/containers", req) .then((res) => { if (res.status !== 200) { - return res.data + return { status: res.status, error: res.data } } - return { container: res.data.container } + return { status: res.status, data: toContainer(res.data.container) } }) diff --git a/src/client/image.ts b/src/client/image.ts index d299eb8..e6a437e 100644 --- a/src/client/image.ts +++ b/src/client/image.ts @@ -1,4 +1,4 @@ -import { client, ErrorResponse, remapFields } from "./index" +import { ApiResponse, client, remapFields } from "./index" export interface ImageSummary { name: string @@ -13,14 +13,10 @@ const toImageSummary = (data: object): ImageSummary => last_push: "lastPush", }) -export interface ListImageResponse { - images: ImageSummary[] -} - -export const getImages = (): Promise => +export const getImages = (): Promise> => client.get("/v1/images").then((res) => { if (res.status !== 200) { - return res.data + return { status: res.status, error: res.data } } - return { images: res.data.images.map((data: object) => toImageSummary(data)) } + return { status: res.status, data: res.data.images.map((data: object) => toImageSummary(data)) } }) diff --git a/src/client/index.ts b/src/client/index.ts index 70e8ca1..9a96b9b 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -19,11 +19,16 @@ export const remapFields = (obj: any, fields: any): any => .map(([key, value]) => [fields[key] || key, value]) .reduce((prev, [key, value]) => ({ ...prev, [key]: value }), {}) -export interface ErrorResponse { - message: string - errors?: object +export interface ApiResponse { + status: number + data?: T + error?: { + message: string + fields?: object + } } + export default { ...account } export { container, account, image } diff --git a/src/components/Account/Account.tsx b/src/components/Account/Account.tsx index a198b27..ad346de 100644 --- a/src/components/Account/Account.tsx +++ b/src/components/Account/Account.tsx @@ -1,34 +1,81 @@ -import React, { useState } from "react" +import React, { useReducer } from "react" import { Header } from "../Layout" import { Button, FormGroup, FormSection, Input } from "../Form" import { Col, Container, Row } from "../Responsive" import { useDispatch, useMappedState } from "redux-react-hook" import client from "../../client" +type Action = + { type: "SET_LOADING", loading: boolean } | + { type: "SET_REVEAL", reveal: boolean } | + { type: "SET_ERROR", error: string } + +interface IState { + loading: boolean + reveal: boolean + error?: string +} + +const reducer = (state: IState, action: Action) => { + switch (action.type) { + case "SET_LOADING": + return { + ...state, + loading: action.loading, + } + case "SET_REVEAL": + return { + ...state, + reveal: action.reveal, + } + case "SET_ERROR": + return { + ...state, + error: action.error, + } + default: + return state + } +} + export default () => { + const [state, dispatch] = useReducer(reducer, { + loading: false, + reveal: false, + }) const account = useMappedState(state => state.account.account) - const dispatch = useDispatch() - const [reveal, setReveal] = useState(false) - const [apiError, setApiError] = useState() + const reduxDispatch = useDispatch() const regenerateApiKeyHandler = () => { + dispatch({ type: "SET_LOADING", loading: true }) + client.regenerateApiKey() - .then((data) => { - dispatch({ type: "SET_ACCOUNT", account: data }) - document.cookie = `token=${data.apiKey}` - setReveal(true) - setApiError(undefined) + .then((res) => { + if (res.error) { + dispatch({ type: "SET_ERROR", error: res.error.message }) + } else if (res.data) { + reduxDispatch({ type: "SET_ACCOUNT", account: res.data }) + document.cookie = `token=${res.data.apiKey}` + dispatch({ type: "SET_REVEAL", reveal: true }) + } }) - .catch((error) => setApiError(error)) + .catch((error) => dispatch({ type: "SET_ERROR", error: error.message })) + .finally(() => dispatch({ type: "SET_LOADING", loading: false })) } const syncAccountHandler = () => { + dispatch({ type: "SET_LOADING", loading: true }) + client.syncAccount() - .then((data) => { - dispatch({ type: "SET_ACCOUNT", account: data }) - setApiError(undefined) + .then((res) => { + if (res.error) { + dispatch({ type: "SET_ERROR", error: res.error.message }) + } else if (res.data) { + reduxDispatch({ type: "SET_ACCOUNT", account: res.data }) + } }) - .catch((error) => setApiError(error)) + .catch((error) => dispatch({ type: "SET_ERROR", error: error.message })) + .finally(() => dispatch({ type: "SET_LOADING", loading: false })) } return ( @@ -36,9 +83,9 @@ export default () => {
- {apiError && ( + {state.error && (
- {apiError} + {state.error}
)} { - - diff --git a/src/components/App.tsx b/src/components/App.tsx index 3296878..0de75e8 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -1,33 +1,67 @@ -import React from "react" +import React, { useEffect, useState } from "react" import { Redirect, Route, Switch } from "react-router-dom" import { Footer, Navbar } from "./Layout" import { ListContainer, NewContainer } from "./Container" import { ListImage } from "./Image" import { Account } from "./Account" -import { account } from "../client" -import { usePromise } from "../hooks" import { useDispatch } from "redux-react-hook" +import client from "../client" export default () => { - const { data, loading, error } = usePromise(async () => { - const res = await account.getAccount() as account.AccountResponse - if (res.account) { - return res.account + const [loading, setLoading] = useState(true) + const [error, setError] = useState() + const dispatch = useDispatch() + + useEffect(() => { + let _cancelled = false + + client.getAccount() + .then((res) => { + if (_cancelled) { + return + } + if (res.error) { + if (res.status === 403) { + window.location.href = process.env.AUTH_URL || "http://localhost:3002/oauth/github" + } else { + setError(res.error.message) + } + } else { + dispatch({ type: "SET_ACCOUNT", account: res.data }) + } + }) + .catch((error) => { + if (_cancelled) { + return + } + setError(error.message) + }) + .finally(() => { + if (_cancelled) { + return + } + setLoading(false) + }) + return () => { + _cancelled = true } }, []) - const dispatch = useDispatch() - if (data) { - dispatch({ type: "SET_ACCOUNT", account: data }) - } else if (error) { - window.location.href = process.env.AUTH_URL || "http://localhost:3002/oauth/github" + if (loading) { + return ( +

+ Loading... +

+ ) + } + + if (error) { + return ( +

Error: {error}

+ ) } - return loading ? ( -

- Loading... -

- ) : ( + return ( <> From 17d56f0353f997daff79c94923e96802058e5e81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Caumette?= Date: Thu, 18 Apr 2019 17:51:28 +0200 Subject: [PATCH 28/28] update --- src/client/index.ts | 8 +-- src/components/Container/ListContainer.tsx | 79 +++++++++++++++++----- src/components/Container/NewContainer.tsx | 13 ++-- src/components/Image/ListImage.tsx | 75 ++++++++++++++++---- 4 files changed, 132 insertions(+), 43 deletions(-) diff --git a/src/client/index.ts b/src/client/index.ts index 9a96b9b..3be2e00 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -27,8 +27,6 @@ export interface ApiResponse { fields?: object } } - - -export default { ...account } - -export { container, account, image } +const a = { ...container, ...account, ...image } +console.log(a) +export default a diff --git a/src/components/Container/ListContainer.tsx b/src/components/Container/ListContainer.tsx index 99b4244..2cdf1fc 100644 --- a/src/components/Container/ListContainer.tsx +++ b/src/components/Container/ListContainer.tsx @@ -1,7 +1,6 @@ -import React from "react" +import React, { useEffect, useReducer } from "react" import TimeAgo from "react-timeago" -import { container } from "../../client" -import { usePromise } from "../../hooks" +import client from "../../client" import { Header } from "../Layout" import { Container } from "../Responsive" import { Card, CardTable } from "../Card" @@ -22,7 +21,7 @@ const Tag = styled.div` const columns = [ { title: "Name", - render: (data: container.Container) => ( + render: (data: client.Container) => ( <> {data.name} @@ -34,11 +33,11 @@ const columns = [ }, { title: "Created", - render: (data: container.Container) => , + render: (data: client.Container) => , }, { title: "Tags", - render: (data: container.Container) => ( + render: (data: client.Container) => ( <> {data.tags.map((tag: any, i: number) => ( {tag} @@ -68,12 +67,58 @@ const columns = [ }, ] +type Action = + { type: "SET_CONTAINERS", containers: client.Container[] } | + { type: "SET_LOADING", loading: boolean } | + { type: "SET_ERROR", error: string } + +interface IState { + loading: boolean + containers: client.Container[] + error?: string +} + +const reducer = (state: IState, action: Action) => { + switch (action.type) { + case "SET_LOADING": + return { + ...state, + loading: action.loading, + } + case "SET_CONTAINERS": + return { + ...state, + containers: action.containers, + } + case "SET_ERROR": + return { + ...state, + error: action.error, + } + default: + return state + } +} + export default () => { - const { loading, data, error } = usePromise(async () => { - const res = await container.getContainers() as container.ListContainerResponse - if (res.containers) { - return res.containers - } + const [state, dispatch] = useReducer(reducer, { + loading: true, + containers: [], + }) + + useEffect(() => { + dispatch({ type: "SET_LOADING", loading: true }) + + client.getContainers() + .then((res) => { + if (res.error) { + dispatch({ type: "SET_ERROR", error: res.error.message }) + } else if (res.data) { + dispatch({ type: "SET_CONTAINERS", containers: res.data }) + } + }) + .catch((error) => dispatch({ type: "SET_ERROR", error: error.message })) + .finally(() => dispatch({ type: "SET_LOADING", loading: false })) }, []) return ( @@ -85,14 +130,14 @@ export default () => {
- {error && ( -

Error: {error.message}...

+ {state.error && ( +

Error: {state.error}...

)} - - {data && ( + + {state.containers && ( - columns={columns} dataSource={data} - onRowClick={(data) => console.log(data)}/> + columns={columns} dataSource={state.containers} + onRowClick={(data) => console.log(data)}/> )} diff --git a/src/components/Container/NewContainer.tsx b/src/components/Container/NewContainer.tsx index 77ed7d2..e4b4078 100644 --- a/src/components/Container/NewContainer.tsx +++ b/src/components/Container/NewContainer.tsx @@ -1,11 +1,12 @@ -import React, { FormEvent, useEffect, useReducer } from "react" +import React, { FormEvent, useReducer } from "react" import { Header } from "../Layout" import { AutocompleteInput, Button, Form, FormGroup, FormSection, Input, TagInput } from "../Form" import { Col, Container, Row } from "../Responsive" -import { Plan, PlanTable } from "./Plan" -import { container } from "../../client" +import { PlanTable } from "./Plan" +import client from "../../client" -interface IContainerPlan {} +interface IContainerPlan { +} type Action = { type: "SET_LOADING", loading: boolean } | @@ -14,7 +15,7 @@ type Action = interface IState { loading: boolean - form: container.CreateContainerRequest + form: client.CreateContainerRequest plans: IContainerPlan[] error?: string formError?: { @@ -72,7 +73,7 @@ export default () => { event.preventDefault() dispatch({ type: "SET_LOADING", loading: true }) - container.createContainer(state.form) + client.createContainer(state.form) .then(console.log) .catch(console.error) .finally(() => dispatch({ type: "SET_LOADING", loading: false })) diff --git a/src/components/Image/ListImage.tsx b/src/components/Image/ListImage.tsx index 51dd171..f6212bc 100644 --- a/src/components/Image/ListImage.tsx +++ b/src/components/Image/ListImage.tsx @@ -1,14 +1,13 @@ -import React from "react" +import React, { useEffect, useReducer } from "react" import TimeAgo from "react-timeago" import { Header } from "../Layout" -import { usePromise } from "../../hooks" import { Loader } from "../Loader" import { Card, CardBody, CardTable } from "../Card" import { Container } from "../Responsive" import { styled } from "../../style" import { Dropdown, DropdownButton, DropdownContent, DropdownItem } from "../Dropdown" import { useMappedState } from "redux-react-hook" -import { image } from "../../client" +import client from "../../client" const NoImage = styled(CardBody)` h3 { @@ -72,28 +71,74 @@ const columns = [ }, ] +type Action = + { type: "SET_IMAGES", images: client.ImageSummary[] } | + { type: "SET_LOADING", loading: boolean } | + { type: "SET_ERROR", error: string } + +interface IState { + loading: boolean + images: client.ImageSummary[] + error?: string +} + +const reducer = (state: IState, action: Action) => { + switch (action.type) { + case "SET_LOADING": + return { + ...state, + loading: action.loading, + } + case "SET_IMAGES": + return { + ...state, + images: action.images, + } + case "SET_ERROR": + return { + ...state, + error: action.error, + } + default: + return state + } +} + export default () => { - const { loading, data, error } = usePromise(async () => { - const res = await image.getImages() as image.ListImageResponse - if (res.images) { - return res.images - } - }, []) const account = useMappedState(state => state.account.account) + const [state, dispatch] = useReducer(reducer, { + loading: true, + images: [], + }) + + useEffect(() => { + dispatch({ type: "SET_LOADING", loading: true }) + + client.getImages() + .then((res) => { + if (res.error) { + dispatch({ type: "SET_ERROR", error: res.error.message }) + } else if (res.data) { + dispatch({ type: "SET_IMAGES", images: res.data }) + } + }) + .catch((error) => dispatch({ type: "SET_ERROR", error: error.message })) + .finally(() => dispatch({ type: "SET_LOADING", loading: false })) + }, []) return ( <>
- {error && ( -

Error: {error.message}...

+ {state.error && ( +

Error: {state.error}...

)} - - {data && ( + + {state.images && ( - {data.length ? ( - columns={columns} dataSource={data} + {state.images.length ? ( + columns={columns} dataSource={state.images} onRowClick={(data) => console.log(data)}/> ) : (