Skip to content

Commit

Permalink
implement language as state instead of cookie (#1785)
Browse files Browse the repository at this point in the history
close #1365 close #1662 close #1692 close #1530
- Detect language from URL instead of cookie.

Steps to verify this.
- In tab 1, choose a task in language en.
- Open tab 2, change the language to a different one, and complete 1
task.
- Back to tab 1 to finish the pending task. Verify that the next task
and the UI are still in English.

This change is really **critical**, so be careful when deploying to
production, better to test it in staging with our community first
  • Loading branch information
notmd committed Feb 21, 2023
1 parent 96feac0 commit bc973ba
Show file tree
Hide file tree
Showing 15 changed files with 111 additions and 90 deletions.
6 changes: 1 addition & 5 deletions website/.eslintrc.json
@@ -1,10 +1,6 @@
{
"root": true,
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"next/core-web-vitals"
],
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "next/core-web-vitals"],
"rules": {
"unused-imports/no-unused-imports": "warn",
"simple-import-sort/imports": "warn",
Expand Down
2 changes: 1 addition & 1 deletion website/cypress/e2e/tasks/no_tasks_available.cy.ts
Expand Up @@ -4,7 +4,7 @@ describe("no tasks available", () => {
cy.intercept(
{
method: "GET",
url: "/api/new_task/prompter_reply",
url: "/api/new_task/prompter_reply?lang=en",
},
{
statusCode: 500,
Expand Down
2 changes: 1 addition & 1 deletion website/cypress/support/commands.ts
Expand Up @@ -51,7 +51,7 @@ Cypress.Commands.add("signInUsingEmailedLink", (emailAddress) => {
// we do a GET to this url to force the python backend to add an entry for our user
// in the database, otherwise the tos acceptance will error with 404 user not found
// then we accept the tos
cy.request("GET", "/api/available_tasks").then(() => cy.request("POST", "/api/tos", {}));
cy.request("GET", "/api/available_tasks?lang=en").then(() => cy.request("POST", "/api/tos", {}));
});
});

Expand Down
5 changes: 2 additions & 3 deletions website/src/components/Messages/LabelFlagGroup.tsx
@@ -1,6 +1,6 @@
import { Button, Flex, Tooltip } from "@chakra-ui/react";
import { useTranslation } from "next-i18next";
import { useCookies } from "react-cookie";
import { useCurrentLocale } from "src/hooks/locale/useCurrentLocale";
import { getTypeSafei18nKey } from "src/lib/i18n";
import { getLocaleDisplayName } from "src/lib/languages";

Expand All @@ -20,8 +20,7 @@ export const LabelFlagGroup = ({
onChange,
}: LabelFlagGroupProps) => {
const { t } = useTranslation("labelling");
const [cookies] = useCookies(["NEXT_LOCALE"]);
const currentLanguage = cookies["NEXT_LOCALE"];
const currentLanguage = useCurrentLocale();
const expectedLanguageName = getLocaleDisplayName(expectedLanguage, currentLanguage);
return (
<Flex wrap="wrap" gap="4">
Expand Down
3 changes: 3 additions & 0 deletions website/src/hooks/locale/useCurrentLocale.ts
@@ -0,0 +1,3 @@
import { useRouter } from "next/router";

export const useCurrentLocale = () => useRouter().locale || "en";
16 changes: 10 additions & 6 deletions website/src/hooks/tasks/useGenericTaskAPI.tsx
@@ -1,27 +1,31 @@
import { useCallback, useState } from "react";
import { TaskInfos } from "src/components/Tasks/TaskTypes";
import { get, post } from "src/lib/api";
import { API_ROUTES } from "src/lib/routes";
import { TaskApiHook } from "src/types/Hooks";
import { BaseTask, ServerTaskResponse, TaskResponse, TaskType as TaskTypeEnum } from "src/types/Task";
import { AllTaskReplies } from "src/types/TaskResponses";
import useSWRImmutable from "swr/immutable";
import useSWRMutation from "swr/mutation";

import { useCurrentLocale } from "../locale/useCurrentLocale";

export const useGenericTaskAPI = <TaskType extends BaseTask, ResponseContent = AllTaskReplies>(
taskType: TaskTypeEnum
): TaskApiHook<TaskType, ResponseContent> => {
const [response, setResponse] = useState<TaskResponse<TaskType>>({ taskAvailability: "AWAITING_INITIAL" });

const locale = useCurrentLocale();
// Note: We use isValidating to indicate we are loading because it signals eash load, not just the first one.
const { isValidating: isLoading, mutate: requestNewTask } = useSWRImmutable<ServerTaskResponse<TaskType>>(
"/api/new_task/" + taskType,
API_ROUTES.NEW_TASK(taskType, { lang: locale }),
get,
{
onSuccess: (taskResponse) => {
setResponse({
...taskResponse,
taskAvailability: "AVAILABLE",
taskInfo: TaskInfos.find((taskType) => taskType.type === taskResponse.task.type),
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
taskInfo: TaskInfos.find((taskType) => taskType.type === taskResponse.task.type)!,
});
},
onError: () => {
Expand All @@ -33,7 +37,7 @@ export const useGenericTaskAPI = <TaskType extends BaseTask, ResponseContent = A
}
);

const { trigger: sendTaskContent } = useSWRMutation("/api/update_task", post, {
const { trigger: sendTaskContent } = useSWRMutation(API_ROUTES.UPDATE_TASK, post, {
onSuccess: () => {
requestNewTask();
},
Expand Down Expand Up @@ -63,9 +67,9 @@ export const useGenericTaskAPI = <TaskType extends BaseTask, ResponseContent = A
if (response.taskAvailability !== "AVAILABLE") {
throw new Error("Cannot complete task that is not yet ready");
}
await sendTaskContent({ id: response.id, update_type: response.taskInfo.update_type, content });
await sendTaskContent({ id: response.id, update_type: response.taskInfo.update_type, content, lang: locale });
},
[response, sendTaskContent]
[response, sendTaskContent, locale]
);

return { response, isLoading, rejectTask, completeTask };
Expand Down
23 changes: 22 additions & 1 deletion website/src/lib/languages.ts
@@ -1,3 +1,7 @@
import { NextApiRequest } from "next";

import { OasstError } from "./oasst_api_client";

const missingDisplayNamesForLocales = {
eu: "Euskara",
gl: "Galego",
Expand All @@ -6,7 +10,7 @@ const missingDisplayNamesForLocales = {
/**
* Returns the locale's name.
*/
export const getLocaleDisplayName = (locale: string, displayLocale = undefined) => {
export const getLocaleDisplayName = (locale: string, displayLocale?: string) => {
// Intl defaults to English for locales that are not oficially translated
if (missingDisplayNamesForLocales[locale]) {
return missingDisplayNamesForLocales[locale];
Expand All @@ -15,3 +19,20 @@ export const getLocaleDisplayName = (locale: string, displayLocale = undefined)
// Return the Titlecased version of the language name.
return displayName.charAt(0).toLocaleUpperCase() + displayName.slice(1);
};

export const getLanguageFromRequest = (req: NextApiRequest) => {
const body = req.method === "GET" ? req.query : req.body;
const lang = body["lang"];

if (!lang || typeof lang !== "string") {
throw new OasstError({
message: "Invalid language",
httpStatusCode: -1,
errorCode: -1,
path: req.url!,
method: req.method!,
});
}

return lang;
};
42 changes: 42 additions & 0 deletions website/src/lib/routes.ts
@@ -1,4 +1,46 @@
import { TaskType } from "src/types/Task";

export type RouteQuery = Record<string, string | number | boolean | undefined>;

export const stringifyQuery = (query: RouteQuery | undefined) => {
if (!query) {
return "";
}

const filteredQuery = Object.fromEntries(Object.entries(query).filter(([, value]) => value !== undefined)) as Record<
string,
string
>;

return new URLSearchParams(filteredQuery).toString();
};

const createRoute = (path: string, query?: RouteQuery) => {
if (!query) {
return path;
}

return `${path}?${stringifyQuery(query)}`;
};

export const ROUTES = {
ADMIN_MESSAGE_DETAIL: (id: string) => `/admin/messages/${id}`,
MESSAGE_DETAIL: (id: string) => `/messages/${id}`,
};

export type QueryWithLang<T extends RouteQuery | undefined = undefined> = T extends undefined
? { lang: string }
: T & { lang: string };

const withLang =
<T extends RouteQuery | undefined = undefined>(path: string, q?: T) =>
(query: QueryWithLang<T>) => {
return createRoute(path, { ...q, ...query });
};

export const API_ROUTES = {
NEW_TASK: (type: TaskType, query: QueryWithLang) => createRoute(`/api/new_task/${type}`, query),
UPDATE_TASK: "/api/update_task",
AVAILABLE_TASK: withLang("/api/available_tasks"),
RECENT_MESSAGES: withLang("/api/messages"),
};
26 changes: 0 additions & 26 deletions website/src/lib/users.ts
@@ -1,32 +1,6 @@
import parser from "accept-language-parser";
import type { NextApiRequest } from "next";
import { i18n } from "src/../next-i18next.config";
import prisma from "src/lib/prismadb";
import type { BackendUserCore } from "src/types/Users";

const LOCALE_SET = new Set(i18n.locales);

/**
* Returns the most appropriate user language using the following priority:
*
* 1. The `NEXT_LOCALE` cookie which is set by the client side and will be in
* the set of supported locales.
* 2. The `accept-language` header if it contains a supported locale as set by
* the i18n module.
* 3. "en" as a final fallback.
*/
export const getUserLanguage = (req: NextApiRequest): string => {
const cookieLanguage = req.cookies["NEXT_LOCALE"];
if (cookieLanguage) {
return cookieLanguage;
}
const headerLanguages = parser.parse(req.headers["accept-language"]);
if (headerLanguages.length > 0 && LOCALE_SET.has(headerLanguages[0].code)) {
return headerLanguages[0].code;
}
return "en";
};

/**
* Returns a `BackendUserCore` that can be used for interacting with the Backend service.
*
Expand Down
9 changes: 5 additions & 4 deletions website/src/pages/api/available_tasks.ts
@@ -1,12 +1,13 @@
import { withoutRole } from "src/lib/auth";
import { getLanguageFromRequest } from "src/lib/languages";
import { createApiClientFromUser } from "src/lib/oasst_client_factory";
import { getBackendUserCore, getUserLanguage } from "src/lib/users";
import { getBackendUserCore } from "src/lib/users";

const handler = withoutRole("banned", async (req, res, token) => {
const user = await getBackendUserCore(token.sub);
const oasstApiClient = createApiClientFromUser(user);
const userLanguage = getUserLanguage(req);
const availableTasks = await oasstApiClient.fetch_available_tasks(user, userLanguage);
const oasstApiClient = createApiClientFromUser(user!);
const userLanguage = getLanguageFromRequest(req);
const availableTasks = await oasstApiClient.fetch_available_tasks(user!, userLanguage);
res.status(200).json(availableTasks);
});

Expand Down
4 changes: 2 additions & 2 deletions website/src/pages/api/messages/index.ts
@@ -1,10 +1,10 @@
import { withoutRole } from "src/lib/auth";
import { getLanguageFromRequest } from "src/lib/languages";
import { createApiClient } from "src/lib/oasst_client_factory";
import { getUserLanguage } from "src/lib/users";

const handler = withoutRole("banned", async (req, res, token) => {
const client = await createApiClient(token);
const userLanguage = getUserLanguage(req);
const userLanguage = getLanguageFromRequest(req);
const messages = await client.fetch_recent_messages(userLanguage);
res.status(200).json(messages);
});
Expand Down
11 changes: 6 additions & 5 deletions website/src/pages/api/new_task/[task_type].ts
@@ -1,9 +1,10 @@
import { withoutRole } from "src/lib/auth";
import { ERROR_CODES } from "src/lib/constants";
import { getLanguageFromRequest } from "src/lib/languages";
import { OasstError } from "src/lib/oasst_api_client";
import { createApiClientFromUser } from "src/lib/oasst_client_factory";
import prisma from "src/lib/prismadb";
import { getBackendUserCore, getUserLanguage } from "src/lib/users";
import { getBackendUserCore } from "src/lib/users";

/**
* Returns a new task created from the Task Backend. We do a few things here:
Expand All @@ -16,16 +17,16 @@ import { getBackendUserCore, getUserLanguage } from "src/lib/users";
const handler = withoutRole("banned", async (req, res, token) => {
// Fetch the new task.
const { task_type } = req.query;
const userLanguage = getUserLanguage(req);
const lang = getLanguageFromRequest(req);

const user = await getBackendUserCore(token.sub);
const oasstApiClient = createApiClientFromUser(user);
const oasstApiClient = createApiClientFromUser(user!);
let task;
try {
task = await oasstApiClient.fetchTask(task_type as string, user, userLanguage);
task = await oasstApiClient.fetchTask(task_type as string, user!, lang);
} catch (err) {
if (err instanceof OasstError && err.errorCode === ERROR_CODES.TASK_REQUESTED_TYPE_NOT_AVAILABLE) {
res.status(503).json({});
res.status(503).json(err);
} else {
console.error(err);
res.status(500).json(err);
Expand Down
16 changes: 5 additions & 11 deletions website/src/pages/api/update_task.ts
@@ -1,8 +1,9 @@
import { Prisma } from "@prisma/client";
import { withoutRole } from "src/lib/auth";
import { getLanguageFromRequest } from "src/lib/languages";
import { createApiClient } from "src/lib/oasst_client_factory";
import prisma from "src/lib/prismadb";
import { getBackendUserCore, getUserLanguage } from "src/lib/users";
import { getBackendUserCore } from "src/lib/users";

/**
* Stores the task interaction with the Task Backend and then returns the next task generated.
Expand All @@ -18,6 +19,8 @@ const handler = withoutRole("banned", async (req, res, token) => {
// Parse out the local task ID and the interaction contents.
const { id: frontendId, content, update_type } = req.body;

const lang = getLanguageFromRequest(req);

// do in parallel since they are independent
const [_, registeredTask, oasstApiClient] = await Promise.all([
// Record that the user has done meaningful work and is no longer new.
Expand Down Expand Up @@ -46,18 +49,9 @@ const handler = withoutRole("banned", async (req, res, token) => {
});

const user = await getBackendUserCore(token.sub);
const userLanguage = getUserLanguage(req);
let newTask;
try {
newTask = await oasstApiClient.interactTask(
update_type,
taskId,
frontendId,
interaction.id,
content,
user,
userLanguage
);
newTask = await oasstApiClient.interactTask(update_type, taskId, frontendId, interaction.id, content, user!, lang);
} catch (err) {
console.error(JSON.stringify(err));
return res.status(500).json(err);
Expand Down
22 changes: 6 additions & 16 deletions website/src/pages/dashboard.tsx
@@ -1,34 +1,24 @@
import { Flex } from "@chakra-ui/react";
import Head from "next/head";
import { useTranslation } from "next-i18next";
import { useEffect, useMemo, useState } from "react";
import { useMemo } from "react";
import { LeaderboardWidget, TaskOption, WelcomeCard } from "src/components/Dashboard";
import { getDashboardLayout } from "src/components/Layout";
import { get } from "src/lib/api";
import { AvailableTasks, TaskCategory } from "src/types/Task";
export { getDefaultStaticProps as getStaticProps } from "src/lib/default_static_props";
import { TaskCategoryItem } from "src/components/Dashboard/TaskOption";
import { useCurrentLocale } from "src/hooks/locale/useCurrentLocale";
import { API_ROUTES } from "src/lib/routes";
import useSWR from "swr";

const Dashboard = () => {
const {
t,
i18n: { language },
} = useTranslation(["dashboard", "common", "tasks"]);
const [activeLang, setLang] = useState<string>(null);
const { data, mutate: fetchTasks } = useSWR<AvailableTasks>("/api/available_tasks", get, {
const { t } = useTranslation(["dashboard", "common", "tasks"]);
const lang = useCurrentLocale();
const { data } = useSWR<AvailableTasks>(API_ROUTES.AVAILABLE_TASK({ lang }), get, {
refreshInterval: 2 * 60 * 1000, //2 minutes
revalidateOnMount: false, // triggered in the hook below
});

useEffect(() => {
// re-fetch tasks if the language has changed
if (activeLang !== language) {
setLang(language);
fetchTasks();
}
}, [activeLang, setLang, language, fetchTasks]);

const availableTaskTypes = useMemo(() => {
const taskTypes = filterAvailableTasks(data ?? {});
return { [TaskCategory.Random]: taskTypes };
Expand Down

0 comments on commit bc973ba

Please sign in to comment.