Blocking behaviour while typing fixed + added spinner (#6)
* Some README tweaks * Next's staticProps blocking beheaviour while typing fixed + Added spinner * Testing for previous fix * Faker deprecational update
This commit is contained in:
@@ -52,7 +52,7 @@ Nearly all the *Lingva* instances should supply a pair of public developer APIs:
|
|||||||
|
|
||||||
### REST API v1
|
### REST API v1
|
||||||
|
|
||||||
+ `/api/v1/:source/:target/:query`
|
+ GET `/api/v1/:source/:target/:query`
|
||||||
```typescript
|
```typescript
|
||||||
{
|
{
|
||||||
translation?: string,
|
translation?: string,
|
||||||
@@ -60,7 +60,7 @@ Nearly all the *Lingva* instances should supply a pair of public developer APIs:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
+ `/api/v1/audio/:lang/:query`
|
+ GET `/api/v1/audio/:lang/:query`
|
||||||
```typescript
|
```typescript
|
||||||
{
|
{
|
||||||
audio?: number[],
|
audio?: number[],
|
||||||
@@ -85,7 +85,7 @@ query {
|
|||||||
audio: [Int]
|
audio: [Int]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
audio(lang: String query: String!) {
|
audio(lang: String! query: String!) {
|
||||||
lang: String!
|
lang: String!
|
||||||
text: String
|
text: String
|
||||||
audio: [Int]
|
audio: [Int]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { FC, ChangeEvent } from "react";
|
import { FC, ChangeEvent } from "react";
|
||||||
import { Box, HStack, Textarea, IconButton, Tooltip, useBreakpointValue, useClipboard } from "@chakra-ui/react";
|
import { Box, HStack, Textarea, IconButton, Tooltip, Spinner, useBreakpointValue, useColorModeValue, useClipboard } from "@chakra-ui/react";
|
||||||
import { FaCopy, FaCheck, FaPlay, FaStop } from "react-icons/fa";
|
import { FaCopy, FaCheck, FaPlay, FaStop } from "react-icons/fa";
|
||||||
import { useAudioFromBuffer } from "../hooks";
|
import { useAudioFromBuffer } from "../hooks";
|
||||||
|
|
||||||
@@ -9,12 +9,19 @@ type Props = {
|
|||||||
readOnly?: true,
|
readOnly?: true,
|
||||||
audio?: number[],
|
audio?: number[],
|
||||||
canCopy?: boolean,
|
canCopy?: boolean,
|
||||||
|
isLoading?: boolean,
|
||||||
[key: string]: any
|
[key: string]: any
|
||||||
};
|
};
|
||||||
|
|
||||||
const TranslationArea: FC<Props> = ({ value, onChange, readOnly, audio, canCopy, ...props }) => {
|
const TranslationArea: FC<Props> = ({ value, onChange, readOnly, audio, canCopy, isLoading, ...props }) => {
|
||||||
const { hasCopied, onCopy } = useClipboard(value);
|
const { hasCopied, onCopy } = useClipboard(value);
|
||||||
const { audioExists, isAudioPlaying, onAudioClick } = useAudioFromBuffer(audio);
|
const { audioExists, isAudioPlaying, onAudioClick } = useAudioFromBuffer(audio);
|
||||||
|
const spinnerProps = {
|
||||||
|
size: useBreakpointValue(["lg", null, "xl"]) ?? undefined,
|
||||||
|
color: useColorModeValue("lingva.500", "lingva.200"),
|
||||||
|
emptyColor: useColorModeValue("gray.300", "gray.600")
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
position="relative"
|
position="relative"
|
||||||
@@ -58,6 +65,17 @@ const TranslationArea: FC<Props> = ({ value, onChange, readOnly, audio, canCopy,
|
|||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</HStack>
|
</HStack>
|
||||||
|
{isLoading && <Spinner
|
||||||
|
position="absolute"
|
||||||
|
top="0"
|
||||||
|
bottom="0"
|
||||||
|
left="0"
|
||||||
|
right="0"
|
||||||
|
m="auto"
|
||||||
|
thickness="3px"
|
||||||
|
label="Loading translation"
|
||||||
|
{...spinnerProps}
|
||||||
|
/>}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,20 +6,25 @@ beforeEach(() => {
|
|||||||
cy.visit("/");
|
cy.visit("/");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("switches page correctly on inputs change", () => {
|
it("switches page on inputs change & goes back correctly", () => {
|
||||||
|
// query change
|
||||||
cy.findByRole("textbox", { name: /translation query/i })
|
cy.findByRole("textbox", { name: /translation query/i })
|
||||||
.as("query")
|
.as("query")
|
||||||
.type("palabra");
|
.type("palabra");
|
||||||
|
cy.findByText(/loading translation/i)
|
||||||
|
.should("be.visible");
|
||||||
cy.findByRole("textbox", { name: /translation result/i })
|
cy.findByRole("textbox", { name: /translation result/i })
|
||||||
.as("translation")
|
.as("translation")
|
||||||
.should("have.value", "word")
|
.should("have.value", "word")
|
||||||
.url()
|
.url()
|
||||||
.should("include", "/auto/en/palabra");
|
.should("include", "/auto/en/palabra");
|
||||||
|
// source change
|
||||||
cy.findByRole("combobox", { name: /source language/i })
|
cy.findByRole("combobox", { name: /source language/i })
|
||||||
.as("source")
|
.as("source")
|
||||||
.select("es")
|
.select("es")
|
||||||
.url()
|
.url()
|
||||||
.should("include", "/es/en/palabra");
|
.should("include", "/es/en/palabra");
|
||||||
|
// target change
|
||||||
cy.findByRole("combobox", { name: /target language/i })
|
cy.findByRole("combobox", { name: /target language/i })
|
||||||
.as("target")
|
.as("target")
|
||||||
.select("ca")
|
.select("ca")
|
||||||
@@ -27,6 +32,7 @@ it("switches page correctly on inputs change", () => {
|
|||||||
.should("have.value", "paraula")
|
.should("have.value", "paraula")
|
||||||
.url()
|
.url()
|
||||||
.should("include", "/es/ca/palabra");
|
.should("include", "/es/ca/palabra");
|
||||||
|
// lang switch
|
||||||
cy.findByRole("button", { name: /switch languages/i })
|
cy.findByRole("button", { name: /switch languages/i })
|
||||||
.click()
|
.click()
|
||||||
.get("@source")
|
.get("@source")
|
||||||
@@ -39,6 +45,26 @@ it("switches page correctly on inputs change", () => {
|
|||||||
.should("have.value", "palabra")
|
.should("have.value", "palabra")
|
||||||
.url()
|
.url()
|
||||||
.should("include", "/ca/es/paraula");
|
.should("include", "/ca/es/paraula");
|
||||||
|
|
||||||
|
// history back
|
||||||
|
cy.go("back")
|
||||||
|
.get("@translation")
|
||||||
|
.should("have.value", "paraula");
|
||||||
|
cy.go("back")
|
||||||
|
.get("@source")
|
||||||
|
.should("have.value", "es")
|
||||||
|
.get("@translation")
|
||||||
|
.should("have.value", "word");
|
||||||
|
cy.go("back")
|
||||||
|
.get("@source")
|
||||||
|
.should("have.value", "auto")
|
||||||
|
.get("@translation")
|
||||||
|
.should("have.value", "word");
|
||||||
|
cy.go("back")
|
||||||
|
.get("@translation")
|
||||||
|
.should("be.empty")
|
||||||
|
.get("@query")
|
||||||
|
.should("be.empty");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("switches first loaded page and back and forth on language change", () => {
|
it("switches first loaded page and back and forth on language change", () => {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { retrieveFiltered, replaceBoth } from "../utils/language";
|
|||||||
import langReducer, { Actions, initialState } from "../utils/reducer";
|
import langReducer, { Actions, initialState } from "../utils/reducer";
|
||||||
|
|
||||||
const Page: FC<InferGetStaticPropsType<typeof getStaticProps>> = ({ home, translationRes, audio, errorMsg, initial }) => {
|
const Page: FC<InferGetStaticPropsType<typeof getStaticProps>> = ({ home, translationRes, audio, errorMsg, initial }) => {
|
||||||
const [{ source, target, query, delayedQuery, translation }, dispatch] = useReducer(langReducer, initialState);
|
const [{ source, target, query, delayedQuery, translation, isLoading }, dispatch] = useReducer(langReducer, initialState);
|
||||||
|
|
||||||
const handleChange = (e: ChangeEvent<HTMLTextAreaElement | HTMLSelectElement>) => {
|
const handleChange = (e: ChangeEvent<HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||||
dispatch({
|
dispatch({
|
||||||
@@ -23,9 +23,16 @@ const Page: FC<InferGetStaticPropsType<typeof getStaticProps>> = ({ home, transl
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const state = { ...initial, delayedQuery: initial?.query, translation: translationRes };
|
if (home)
|
||||||
initial && dispatch({ type: Actions.SET_ALL, payload: { state }});
|
return dispatch({ type: Actions.SET_ALL, payload: { state: { ...initialState, isLoading: false } } });
|
||||||
}, [initial, translationRes]);
|
if (!initial)
|
||||||
|
return;
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: Actions.SET_ALL,
|
||||||
|
payload: { state: { ...initial, delayedQuery: initial.query, translation: translationRes, isLoading: false } }
|
||||||
|
});
|
||||||
|
}, [initial, translationRes, home]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeout = setTimeout(() =>
|
const timeout = setTimeout(() =>
|
||||||
@@ -35,6 +42,8 @@ const Page: FC<InferGetStaticPropsType<typeof getStaticProps>> = ({ home, transl
|
|||||||
}, [query]);
|
}, [query]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (isLoading)
|
||||||
|
return;
|
||||||
if (!delayedQuery || delayedQuery === initialState.query)
|
if (!delayedQuery || delayedQuery === initialState.query)
|
||||||
return;
|
return;
|
||||||
if (!home && !initial)
|
if (!home && !initial)
|
||||||
@@ -42,8 +51,15 @@ const Page: FC<InferGetStaticPropsType<typeof getStaticProps>> = ({ home, transl
|
|||||||
if (!home && delayedQuery === initial.query && source === initial.source && target === initial.target)
|
if (!home && delayedQuery === initial.query && source === initial.source && target === initial.target)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
dispatch({ type: Actions.SET_FIELD, payload: { key: "isLoading", value: true }});
|
||||||
Router.push(`/${source}/${target}/${encodeURIComponent(delayedQuery)}`);
|
Router.push(`/${source}/${target}/${encodeURIComponent(delayedQuery)}`);
|
||||||
}, [source, target, delayedQuery, initial, home]);
|
}, [source, target, delayedQuery, initial, home, isLoading]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => dispatch({ type: Actions.SET_FIELD, payload: { key: "isLoading", value: true }});
|
||||||
|
Router.events.on("beforeHistoryChange", handler);
|
||||||
|
return () => Router.events.off("beforeHistoryChange", handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const { sourceLangs, targetLangs } = retrieveFiltered(source, target);
|
const { sourceLangs, targetLangs } = retrieveFiltered(source, target);
|
||||||
const { source: transLang, target: queryLang } = replaceBoth("exception", { source: target, target: source });
|
const { source: transLang, target: queryLang } = replaceBoth("exception", { source: target, target: source });
|
||||||
@@ -88,7 +104,7 @@ const Page: FC<InferGetStaticPropsType<typeof getStaticProps>> = ({ home, transl
|
|||||||
aria-label="Translation query"
|
aria-label="Translation query"
|
||||||
placeholder="Text"
|
placeholder="Text"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={handleChange}
|
onChange={e => isLoading || handleChange(e)}
|
||||||
lang={queryLang}
|
lang={queryLang}
|
||||||
audio={audio?.source}
|
audio={audio?.source}
|
||||||
/>
|
/>
|
||||||
@@ -101,6 +117,7 @@ const Page: FC<InferGetStaticPropsType<typeof getStaticProps>> = ({ home, transl
|
|||||||
lang={transLang}
|
lang={transLang}
|
||||||
audio={audio?.target}
|
audio={audio?.target}
|
||||||
canCopy={true}
|
canCopy={true}
|
||||||
|
isLoading={isLoading}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</VStack>
|
</VStack>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { render, screen } from "../reactUtils";
|
|||||||
import faker from "faker";
|
import faker from "faker";
|
||||||
import CustomError from "../../components/CustomError";
|
import CustomError from "../../components/CustomError";
|
||||||
|
|
||||||
const code = faker.random.number({ min: 400, max: 599 });
|
const code = faker.datatype.number({ min: 400, max: 599 });
|
||||||
const text = faker.random.words();
|
const text = faker.random.words();
|
||||||
|
|
||||||
it("loads the layout correctly", async () => {
|
it("loads the layout correctly", async () => {
|
||||||
@@ -16,7 +16,7 @@ it("loads the layout correctly", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("renders the correct status code & text", () => {
|
it("renders the correct status code & text", () => {
|
||||||
const code = faker.random.number({ min: 400, max: 599 });
|
const code = faker.datatype.number({ min: 400, max: 599 });
|
||||||
render(<CustomError statusCode={code} statusText={text} />);
|
render(<CustomError statusCode={code} statusText={text} />);
|
||||||
|
|
||||||
expect(screen.getByText(code)).toBeVisible();
|
expect(screen.getByText(code)).toBeVisible();
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ describe("getStaticProps", () => {
|
|||||||
|
|
||||||
describe("Page", () => {
|
describe("Page", () => {
|
||||||
const translationRes = faker.random.words();
|
const translationRes = faker.random.words();
|
||||||
const randomAudio = Array.from({ length: 10 }, () => faker.random.number(100));
|
const randomAudio = Array.from({ length: 10 }, () => faker.datatype.number(100));
|
||||||
const audio = {
|
const audio = {
|
||||||
source: randomAudio,
|
source: randomAudio,
|
||||||
target: randomAudio
|
target: randomAudio
|
||||||
@@ -84,11 +84,17 @@ describe("Page", () => {
|
|||||||
userEvent.type(query, faker.random.words());
|
userEvent.type(query, faker.random.words());
|
||||||
|
|
||||||
await waitFor(
|
await waitFor(
|
||||||
() => expect(Router.push).not.toHaveBeenCalled(),
|
() => {
|
||||||
|
expect(Router.push).not.toHaveBeenCalled();
|
||||||
|
expect(screen.queryByText(/loading translation/i)).not.toBeInTheDocument();
|
||||||
|
},
|
||||||
{ timeout: 250 }
|
{ timeout: 250 }
|
||||||
);
|
);
|
||||||
await waitFor(
|
await waitFor(
|
||||||
() => expect(Router.push).toHaveBeenCalledTimes(1),
|
() => {
|
||||||
|
expect(Router.push).toHaveBeenCalledTimes(1);
|
||||||
|
expect(screen.getByText(/loading translation/i)).toBeInTheDocument();
|
||||||
|
},
|
||||||
{ timeout: 2500 }
|
{ timeout: 2500 }
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ it("changes all fields", () => {
|
|||||||
target: faker.random.locale(),
|
target: faker.random.locale(),
|
||||||
query,
|
query,
|
||||||
delayedQuery: query,
|
delayedQuery: query,
|
||||||
translation: faker.random.words()
|
translation: faker.random.words(),
|
||||||
|
isLoading: faker.datatype.boolean()
|
||||||
};
|
};
|
||||||
|
|
||||||
const res = langReducer(initialState, {
|
const res = langReducer(initialState, {
|
||||||
@@ -46,7 +47,8 @@ it("switches the languages & the translations", () => {
|
|||||||
target: state.source,
|
target: state.source,
|
||||||
query: state.translation,
|
query: state.translation,
|
||||||
delayedQuery: state.translation,
|
delayedQuery: state.translation,
|
||||||
translation: ""
|
translation: "",
|
||||||
|
isLoading: initialState.isLoading
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ describe("googleScrape", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns correct message on request error", async () => {
|
it("returns correct message on request error", async () => {
|
||||||
const status = faker.random.number({ min: 400, max: 499 });
|
const status = faker.datatype.number({ min: 400, max: 499 });
|
||||||
resolveFetchWith({ status });
|
resolveFetchWith({ status });
|
||||||
|
|
||||||
const res = await googleScrape(source, target, query);
|
const res = await googleScrape(source, target, query);
|
||||||
@@ -61,7 +61,7 @@ describe("extractSlug", () => {
|
|||||||
it("returns empty object on 0 or >4 params", () => {
|
it("returns empty object on 0 or >4 params", () => {
|
||||||
expect(extractSlug([])).toStrictEqual({});
|
expect(extractSlug([])).toStrictEqual({});
|
||||||
|
|
||||||
const length = faker.random.number({ min: 4, max: 50 });
|
const length = faker.datatype.number({ min: 4, max: 50 });
|
||||||
const array = Array(length).fill("");
|
const array = Array(length).fill("");
|
||||||
expect(extractSlug(array)).toStrictEqual({});
|
expect(extractSlug(array)).toStrictEqual({});
|
||||||
});
|
});
|
||||||
@@ -74,7 +74,7 @@ describe("textToSpeechScrape", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns 'null' on request error", async () => {
|
it("returns 'null' on request error", async () => {
|
||||||
const status = faker.random.number({ min: 400, max: 499 });
|
const status = faker.datatype.number({ min: 400, max: 499 });
|
||||||
resolveFetchWith({ status });
|
resolveFetchWith({ status });
|
||||||
expect(await textToSpeechScrape(target, query)).toBeNull();
|
expect(await textToSpeechScrape(target, query)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ export const initialState = {
|
|||||||
target: "en",
|
target: "en",
|
||||||
query: "",
|
query: "",
|
||||||
delayedQuery: "",
|
delayedQuery: "",
|
||||||
translation: ""
|
translation: "",
|
||||||
|
isLoading: true
|
||||||
}
|
}
|
||||||
|
|
||||||
type State = typeof initialState;
|
type State = typeof initialState;
|
||||||
@@ -20,7 +21,7 @@ type Action = {
|
|||||||
type: Actions.SET_FIELD,
|
type: Actions.SET_FIELD,
|
||||||
payload: {
|
payload: {
|
||||||
key: string,
|
key: string,
|
||||||
value: string
|
value: any
|
||||||
}
|
}
|
||||||
} | {
|
} | {
|
||||||
type: Actions.SET_ALL,
|
type: Actions.SET_ALL,
|
||||||
|
|||||||
Reference in New Issue
Block a user