mirror of
https://github.com/wassname/talk.git
synced 2026-07-02 06:31:39 +08:00
[CORL-406] Tenant Locale Selection (#2450)
* feat: added preload config * feat: support changing locale * fix: name case * fix: removed unused code * feat: added translations for default reactions * fix: do not translate icon * fix: shorter i18n keys
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import convict from "convict";
|
||||
|
||||
import { LOCALES } from "../common/helpers/i18n/locales";
|
||||
|
||||
const config = convict({
|
||||
env: {
|
||||
doc: "The application environment.",
|
||||
@@ -21,6 +23,12 @@ const config = convict({
|
||||
env: "DEV_PORT",
|
||||
arg: "dev-port",
|
||||
},
|
||||
defaultLocale: {
|
||||
doc: "Specify the default locale to use",
|
||||
format: LOCALES,
|
||||
default: "en-US",
|
||||
env: "LOCALE",
|
||||
},
|
||||
generateReport: {
|
||||
doc: "Generate a report using webpack-bundle-analyzer",
|
||||
format: Boolean,
|
||||
|
||||
@@ -92,23 +92,23 @@ export default function createWebpackConfig(
|
||||
const localesOptions = {
|
||||
pathToLocales: paths.appLocales,
|
||||
|
||||
// Default locale if non could be negotiated.
|
||||
defaultLocale: "en-US",
|
||||
// Default locale if none was specified.
|
||||
defaultLocale: config.get("defaultLocale"),
|
||||
|
||||
// Fallback locale if a translation was not found.
|
||||
// If not set, will use the text that is already
|
||||
// in the code base.
|
||||
fallbackLocale: "en-US",
|
||||
fallbackLocale: config.get("defaultLocale"),
|
||||
|
||||
// Common fluent files are always included in the locale bundles.
|
||||
commonFiles: ["framework.ftl", "common.ftl", "ui.ftl"],
|
||||
|
||||
// Locales that come with the main bundle. Others are loaded on demand.
|
||||
bundled: ["en-US"],
|
||||
bundled: [config.get("defaultLocale")],
|
||||
|
||||
// All available locales can be loadable on demand.
|
||||
// To restrict available locales set:
|
||||
// availableLocales: ["en-US"],
|
||||
// availableLocales: [config.get("defaultLocale")],
|
||||
};
|
||||
|
||||
const additionalPlugins = [
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
__webpack_public_path__ =
|
||||
JSON.parse(document.getElementById("config").innerText) || "/";
|
||||
JSON.parse(document.getElementById("config").innerText).staticURI || "/";
|
||||
|
||||
@@ -13,7 +13,6 @@ async function main() {
|
||||
const ManagedCoralContextProvider = await createManaged({
|
||||
initLocalState,
|
||||
localesData,
|
||||
userLocales: navigator.languages,
|
||||
});
|
||||
|
||||
const Index: FunctionComponent = () => (
|
||||
|
||||
@@ -13,7 +13,6 @@ async function main() {
|
||||
const ManagedCoralContextProvider = await createManaged({
|
||||
initLocalState,
|
||||
localesData,
|
||||
userLocales: navigator.languages,
|
||||
});
|
||||
|
||||
const Index: FunctionComponent = () => (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { FormApi, FormState } from "final-form";
|
||||
import React from "react";
|
||||
|
||||
import { CoralContext, withContext } from "coral-framework/lib/bootstrap";
|
||||
import { SubmitHookHandler } from "coral-framework/lib/form";
|
||||
import { MutationProp, withMutation } from "coral-framework/lib/relay";
|
||||
|
||||
@@ -9,6 +10,7 @@ import NavigationWarningContainer from "./NavigationWarningContainer";
|
||||
import UpdateSettingsMutation from "./UpdateSettingsMutation";
|
||||
|
||||
interface Props {
|
||||
changeLocale: CoralContext["changeLocale"];
|
||||
updateSettings: MutationProp<typeof UpdateSettingsMutation>;
|
||||
children: React.ReactElement;
|
||||
}
|
||||
@@ -27,6 +29,10 @@ class ConfigureRoute extends React.Component<Props, State> {
|
||||
form: FormApi
|
||||
) => {
|
||||
await this.props.updateSettings({ settings: data });
|
||||
const localeFieldState = form.getFieldState("locale");
|
||||
if (localeFieldState && localeFieldState.dirty) {
|
||||
await this.props.changeLocale(data.locale);
|
||||
}
|
||||
form.initialize(data);
|
||||
};
|
||||
|
||||
@@ -52,6 +58,8 @@ class ConfigureRoute extends React.Component<Props, State> {
|
||||
}
|
||||
}
|
||||
|
||||
const enhanced = withMutation(UpdateSettingsMutation)(ConfigureRoute);
|
||||
const enhanced = withContext(({ changeLocale }) => ({ changeLocale }))(
|
||||
withMutation(UpdateSettingsMutation)(ConfigureRoute)
|
||||
);
|
||||
|
||||
export default enhanced;
|
||||
|
||||
@@ -8,6 +8,7 @@ import ClosingCommentStreamsConfigContainer from "./ClosingCommentStreamsConfigC
|
||||
import CommentEditingConfigContainer from "./CommentEditingConfigContainer";
|
||||
import CommentLengthConfigContainer from "./CommentLengthConfigContainer";
|
||||
import GuidelinesConfigContainer from "./GuidelinesConfigContainer";
|
||||
import LocaleConfigContainer from "./LocaleConfigContainer";
|
||||
import ReactionConfigContainer from "./ReactionConfigContainer";
|
||||
import SitewideCommentingConfigContainer from "./SitewideCommentingConfigContainer";
|
||||
|
||||
@@ -19,7 +20,8 @@ interface Props {
|
||||
PropTypesOf<typeof ClosedStreamMessageConfigContainer>["settings"] &
|
||||
PropTypesOf<typeof ReactionConfigContainer>["settings"] &
|
||||
PropTypesOf<typeof ClosingCommentStreamsConfigContainer>["settings"] &
|
||||
PropTypesOf<typeof SitewideCommentingConfigContainer>["settings"];
|
||||
PropTypesOf<typeof SitewideCommentingConfigContainer>["settings"] &
|
||||
PropTypesOf<typeof LocaleConfigContainer>["settings"];
|
||||
onInitValues: (values: any) => void;
|
||||
}
|
||||
|
||||
@@ -29,6 +31,11 @@ const General: FunctionComponent<Props> = ({
|
||||
onInitValues,
|
||||
}) => (
|
||||
<HorizontalGutter size="double" data-testid="configure-generalContainer">
|
||||
<LocaleConfigContainer
|
||||
disabled={disabled}
|
||||
settings={settings}
|
||||
onInitValues={onInitValues}
|
||||
/>
|
||||
<SitewideCommentingConfigContainer
|
||||
disabled={disabled}
|
||||
settings={settings}
|
||||
|
||||
@@ -45,6 +45,7 @@ class GeneralConfigContainer extends React.Component<Props> {
|
||||
const enhanced = withFragmentContainer<Props>({
|
||||
settings: graphql`
|
||||
fragment GeneralConfigContainer_settings on Settings {
|
||||
...LocaleConfigContainer_settings
|
||||
...GuidelinesConfigContainer_settings
|
||||
...CommentLengthConfigContainer_settings
|
||||
...CommentEditingConfigContainer_settings
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { useMemo } from "react";
|
||||
import { Field } from "react-final-form";
|
||||
import { graphql } from "react-relay";
|
||||
|
||||
import { LocaleConfigContainer_settings } from "coral-admin/__generated__/LocaleConfigContainer_settings.graphql";
|
||||
import { LocaleField } from "coral-framework/components";
|
||||
import { withFragmentContainer } from "coral-framework/lib/relay";
|
||||
import { required } from "coral-framework/lib/validation";
|
||||
import { FormField, HorizontalGutter, Typography } from "coral-ui/components";
|
||||
|
||||
import Header from "../../Header";
|
||||
import ValidationMessage from "../../ValidationMessage";
|
||||
|
||||
interface Props {
|
||||
settings: LocaleConfigContainer_settings;
|
||||
onInitValues: (values: LocaleConfigContainer_settings) => void;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
const LocaleConfigContainer: React.FunctionComponent<Props> = props => {
|
||||
useMemo(() => props.onInitValues(props.settings), [props.onInitValues]);
|
||||
return (
|
||||
<FormField>
|
||||
<HorizontalGutter size="full">
|
||||
<Localized id="configure-general-locale-language">
|
||||
<Header container={<label htmlFor="configure-locale-locale" />}>
|
||||
Language
|
||||
</Header>
|
||||
</Localized>
|
||||
<Localized
|
||||
id="configure-general-locale-chooseLanguage"
|
||||
strong={<strong />}
|
||||
>
|
||||
<Typography variant="detail">
|
||||
Choose the language for your Coral community.
|
||||
</Typography>
|
||||
</Localized>
|
||||
<Field name="locale" validate={required}>
|
||||
{({ input, meta }) => (
|
||||
<>
|
||||
<LocaleField
|
||||
id={`configure-locale-${input.name}`}
|
||||
disabled={props.disabled}
|
||||
{...input}
|
||||
/>
|
||||
<ValidationMessage meta={meta} fullWidth />
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
</HorizontalGutter>
|
||||
</FormField>
|
||||
);
|
||||
};
|
||||
|
||||
const enhanced = withFragmentContainer<Props>({
|
||||
settings: graphql`
|
||||
fragment LocaleConfigContainer_settings on Settings {
|
||||
locale
|
||||
}
|
||||
`,
|
||||
})(LocaleConfigContainer);
|
||||
|
||||
export default enhanced;
|
||||
@@ -118,6 +118,81 @@ exports[`renders configure general 1`] = `
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-double"
|
||||
data-testid="configure-generalContainer"
|
||||
>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-half"
|
||||
>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-full"
|
||||
>
|
||||
<label
|
||||
className="Box-root Typography-root Typography-heading1 Typography-colorTextPrimary Header-root"
|
||||
htmlFor="configure-locale-locale"
|
||||
>
|
||||
Language
|
||||
</label>
|
||||
<p
|
||||
className="Box-root Typography-root Typography-detail Typography-colorTextPrimary"
|
||||
>
|
||||
Choose the language for your Coral community.
|
||||
</p>
|
||||
<span
|
||||
className="SelectField-root"
|
||||
>
|
||||
<select
|
||||
className="SelectField-select"
|
||||
disabled={false}
|
||||
id="configure-locale-locale"
|
||||
name="locale"
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onFocus={[Function]}
|
||||
value="en-US"
|
||||
>
|
||||
<option
|
||||
value="en-US"
|
||||
>
|
||||
English
|
||||
</option>
|
||||
<option
|
||||
value="pt-BR"
|
||||
>
|
||||
Português brasileiro
|
||||
</option>
|
||||
<option
|
||||
value="es"
|
||||
>
|
||||
Español
|
||||
</option>
|
||||
<option
|
||||
value="de"
|
||||
>
|
||||
Deutsch
|
||||
</option>
|
||||
<option
|
||||
value="nl-NL"
|
||||
>
|
||||
Nederlands
|
||||
</option>
|
||||
<option
|
||||
value="da"
|
||||
>
|
||||
Dansk
|
||||
</option>
|
||||
</select>
|
||||
<span
|
||||
aria-hidden={true}
|
||||
className="SelectField-afterWrapper"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-sm"
|
||||
>
|
||||
expand_more
|
||||
</i>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<fieldset
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-oneAndAHalf"
|
||||
>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { SinonStub } from "sinon";
|
||||
|
||||
import { ERROR_CODES } from "coral-common/errors";
|
||||
import { pureMerge } from "coral-common/utils";
|
||||
import { InvalidRequestError } from "coral-framework/lib/errors";
|
||||
@@ -63,6 +65,48 @@ it("renders configure general", async () => {
|
||||
expect(within(configureContainer).toJSON()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("change language", async () => {
|
||||
const resolvers = createResolversStub<GQLResolver>({
|
||||
Mutation: {
|
||||
updateSettings: ({ variables }) => {
|
||||
expectAndFail(variables.settings.locale).toEqual("es");
|
||||
return {
|
||||
settings: pureMerge(settings, variables.settings),
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
const {
|
||||
context: { changeLocale },
|
||||
configureContainer,
|
||||
generalContainer,
|
||||
saveChangesButton,
|
||||
} = await createTestRenderer({ resolvers });
|
||||
|
||||
const languageField = within(generalContainer).getByLabelText("Language");
|
||||
|
||||
// Let's change the language.
|
||||
languageField.props.onChange("es");
|
||||
|
||||
// Send form
|
||||
within(configureContainer)
|
||||
.getByType("form")
|
||||
.props.onSubmit();
|
||||
|
||||
// Submit button and text field should be disabled.
|
||||
expect(saveChangesButton.props.disabled).toBe(true);
|
||||
|
||||
// Wait for submission to be finished
|
||||
await wait(() => {
|
||||
expect(resolvers.Mutation!.updateSettings!.called).toBe(true);
|
||||
});
|
||||
|
||||
// Wait for client to change language.
|
||||
await wait(() => {
|
||||
expect((changeLocale as SinonStub).called).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("change site wide commenting", async () => {
|
||||
const resolvers = createResolversStub<GQLResolver>({
|
||||
Mutation: {
|
||||
|
||||
@@ -24,6 +24,7 @@ export const settings = createFixture<GQLSettings>({
|
||||
id: "settings",
|
||||
moderation: GQLMODERATION_MODE.POST,
|
||||
premodLinksEnable: false,
|
||||
locale: "en-US",
|
||||
live: {
|
||||
enabled: true,
|
||||
configurable: true,
|
||||
|
||||
@@ -33,7 +33,6 @@ async function main() {
|
||||
const ManagedCoralContextProvider = await createManaged({
|
||||
initLocalState,
|
||||
localesData,
|
||||
userLocales: navigator.languages,
|
||||
});
|
||||
|
||||
const Index: FunctionComponent = () => (
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import React, { FunctionComponent } from "react";
|
||||
|
||||
import { LanguageCode, LOCALES_MAP } from "coral-common/helpers/i18n";
|
||||
import { PropTypesOf } from "coral-framework/types";
|
||||
import { Option, SelectField } from "coral-ui/components";
|
||||
|
||||
interface Props extends PropTypesOf<typeof SelectField> {
|
||||
value: LanguageCode;
|
||||
}
|
||||
|
||||
const LocaleField: FunctionComponent<Props> = props => {
|
||||
return (
|
||||
<SelectField {...props}>
|
||||
{Object.keys(LOCALES_MAP).map((lang: LanguageCode) => {
|
||||
return (
|
||||
<Option value={lang} key={lang}>
|
||||
{LOCALES_MAP[lang]}
|
||||
</Option>
|
||||
);
|
||||
})}
|
||||
</SelectField>
|
||||
);
|
||||
};
|
||||
|
||||
export default LocaleField;
|
||||
@@ -6,3 +6,4 @@ export { default as OIDCButton } from "./OIDCButton";
|
||||
export { default as Markdown } from "./Markdown";
|
||||
export { default as FadeInTransition } from "./FadeInTransition";
|
||||
export { default as DurationField, DURATION_UNIT } from "./DurationField";
|
||||
export { default as LocaleField } from "./LocaleField";
|
||||
|
||||
@@ -7,6 +7,7 @@ import { MediaQueryMatchers } from "react-responsive";
|
||||
import { Formatter } from "react-timeago";
|
||||
import { Environment } from "relay-runtime";
|
||||
|
||||
import { LanguageCode } from "coral-common/helpers/i18n";
|
||||
import { BrowserInfo } from "coral-framework/lib/browserInfo";
|
||||
import { PostMessageService } from "coral-framework/lib/postMessage";
|
||||
import { RestClient } from "coral-framework/lib/rest";
|
||||
@@ -64,6 +65,9 @@ export interface CoralContext {
|
||||
/** Clear session data. */
|
||||
clearSession: (nextAccessToken?: string | null) => Promise<void>;
|
||||
|
||||
/** Change locale and rerender */
|
||||
changeLocale: (locale: LanguageCode) => Promise<void>;
|
||||
|
||||
/** Controls router transitions (for tests) */
|
||||
transitionControl?: TransitionControlData;
|
||||
}
|
||||
|
||||
@@ -7,12 +7,14 @@ import { Formatter } from "react-timeago";
|
||||
import { Environment, RecordSource, Store } from "relay-runtime";
|
||||
import uuid from "uuid/v1";
|
||||
|
||||
import { LanguageCode } from "coral-common/helpers/i18n";
|
||||
import { getBrowserInfo } from "coral-framework/lib/browserInfo";
|
||||
import {
|
||||
commitLocalUpdatePromisified,
|
||||
LOCAL_ID,
|
||||
setAccessTokenInLocalState,
|
||||
} from "coral-framework/lib/relay";
|
||||
import { RestClient } from "coral-framework/lib/rest";
|
||||
import {
|
||||
createLocalStorage,
|
||||
createPromisifiedStorage,
|
||||
@@ -20,11 +22,9 @@ import {
|
||||
createSessionStorage,
|
||||
PromisifiedStorage,
|
||||
} from "coral-framework/lib/storage";
|
||||
|
||||
import { RestClient } from "coral-framework/lib/rest";
|
||||
import { ClickFarAwayRegister } from "coral-ui/components/ClickOutside";
|
||||
|
||||
import { generateBundles, LocalesData, negotiateLanguages } from "../i18n";
|
||||
import { generateBundles, LocalesData } from "../i18n";
|
||||
import {
|
||||
createManagedSubscriptionClient,
|
||||
createNetwork,
|
||||
@@ -41,9 +41,6 @@ export type InitLocalState = (
|
||||
) => void | Promise<void>;
|
||||
|
||||
interface CreateContextArguments {
|
||||
/** Locales that the user accepts, usually `navigator.languages`. */
|
||||
userLocales: ReadonlyArray<string>;
|
||||
|
||||
/** Locales data that is returned by our `locales-loader`. */
|
||||
localesData: LocalesData;
|
||||
|
||||
@@ -128,11 +125,12 @@ function createRestClient(tokenGetter: () => string, clientID: string) {
|
||||
* Returns a managed CoralContextProvider, that includes given context
|
||||
* and handles context changes, e.g. when a user session changes.
|
||||
*/
|
||||
function createMangedCoralContextProvider(
|
||||
function createManagedCoralContextProvider(
|
||||
context: CoralContext,
|
||||
subscriptionClient: ManagedSubscriptionClient,
|
||||
clientID: string,
|
||||
initLocalState: InitLocalState
|
||||
initLocalState: InitLocalState,
|
||||
localesData: LocalesData
|
||||
) {
|
||||
const ManagedCoralContextProvider = class extends Component<
|
||||
{},
|
||||
@@ -144,6 +142,7 @@ function createMangedCoralContextProvider(
|
||||
context: {
|
||||
...context,
|
||||
clearSession: this.clearSession,
|
||||
changeLocale: this.changeLocale,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -196,6 +195,25 @@ function createMangedCoralContextProvider(
|
||||
);
|
||||
};
|
||||
|
||||
// This is called when the locale should change.
|
||||
private changeLocale = async (locale: LanguageCode) => {
|
||||
// Add fallback locale.
|
||||
const locales = [localesData.fallbackLocale];
|
||||
if (locale && locale !== localesData.fallbackLocale) {
|
||||
locales.splice(0, 0, locale);
|
||||
}
|
||||
const localeBundles = await generateBundles(locales, localesData);
|
||||
const newContext = {
|
||||
...this.state.context,
|
||||
locales,
|
||||
localeBundles,
|
||||
};
|
||||
// Propagate new context.
|
||||
this.setState({
|
||||
context: newContext,
|
||||
});
|
||||
};
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<CoralContextProvider value={this.state.context}>
|
||||
@@ -240,7 +258,6 @@ function resolveSessionStorage(pym?: PymChild): PromisifiedStorage {
|
||||
*/
|
||||
export default async function createManaged({
|
||||
initLocalState = noop,
|
||||
userLocales,
|
||||
localesData,
|
||||
pym,
|
||||
eventEmitter = new EventEmitter2({ wildcard: true, maxListeners: 20 }),
|
||||
@@ -261,11 +278,24 @@ export default async function createManaged({
|
||||
}
|
||||
|
||||
// Initialize i18n.
|
||||
const locales = negotiateLanguages(userLocales, localesData);
|
||||
const locales = [localesData.fallbackLocale];
|
||||
if (
|
||||
document.documentElement.lang &&
|
||||
document.documentElement.lang !== localesData.fallbackLocale
|
||||
) {
|
||||
// Use locale specified by the server.
|
||||
locales.splice(0, 0, document.documentElement.lang);
|
||||
} else if (
|
||||
localesData.defaultLocale &&
|
||||
localesData.defaultLocale !== localesData.fallbackLocale
|
||||
) {
|
||||
// Use default locale.
|
||||
locales.splice(0, 0, localesData.defaultLocale);
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
// tslint:disable:next-line: no-console
|
||||
console.log(`Negotiated locales ${JSON.stringify(locales)}`);
|
||||
console.log(`Using locales ${JSON.stringify(locales)}`);
|
||||
}
|
||||
|
||||
const localeBundles = await generateBundles(locales, localesData);
|
||||
@@ -304,6 +334,9 @@ export default async function createManaged({
|
||||
// Noop, this is later replaced by the
|
||||
// managed CoralContextProvider.
|
||||
clearSession: (nextAccessToken?: string | null) => Promise.resolve(),
|
||||
// Noop, this is later replaced by the
|
||||
// managed CoralContextProvider.
|
||||
changeLocale: (locale?: LanguageCode) => Promise.resolve(),
|
||||
};
|
||||
|
||||
// Initialize local state.
|
||||
@@ -317,10 +350,11 @@ export default async function createManaged({
|
||||
|
||||
// Returns a managed CoralContextProvider, that includes the above
|
||||
// context and handles context changes, e.g. when a user session changes.
|
||||
return createMangedCoralContextProvider(
|
||||
return createManagedCoralContextProvider(
|
||||
context,
|
||||
subscriptionClient,
|
||||
clientID,
|
||||
initLocalState
|
||||
initLocalState,
|
||||
localesData
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,8 +36,6 @@ if (process.env.NODE_ENV !== "production") {
|
||||
* Given a locales array and the `data` from the `locales-loader`,
|
||||
* generateMessages returns an Array of MessageContext as a Promise.
|
||||
* This array is meant to be consumed by `react-fluent`.
|
||||
*
|
||||
* Use it in conjunction with `negotiateLanguages`.
|
||||
*/
|
||||
export default async function generateBundles(
|
||||
locales: ReadonlyArray<string>,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export { default as generateBundles } from "./generateBundles";
|
||||
export { default as negotiateLanguages } from "./negotiateLanguages";
|
||||
export { BundledLocales, LoadableLocales, LocalesData } from "./locales";
|
||||
export { default as getMessage } from "./getMessage";
|
||||
export { default as withGetMessage, GetMessage } from "./withGetMessage";
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { negotiateLanguages as negotiate } from "fluent-langneg/compat";
|
||||
|
||||
import { LocalesData } from "./locales";
|
||||
|
||||
/**
|
||||
* negotiateLanguages accepts `userLocales` which usually comes from
|
||||
* `navigator.languages` and the locales `data` as generated by
|
||||
* the `locales-loader` and returns an array of matching languages.
|
||||
*/
|
||||
export default function negotiateLanguages(
|
||||
userLocales: ReadonlyArray<string>,
|
||||
data: LocalesData
|
||||
) {
|
||||
// Choose locale that is best for the user.
|
||||
const languages = negotiate(userLocales, data.availableLocales, {
|
||||
defaultLocale: data.defaultLocale,
|
||||
strategy: "lookup",
|
||||
});
|
||||
|
||||
if (data.fallbackLocale && languages[0] !== data.fallbackLocale) {
|
||||
// Use default locale as fallback in case we have
|
||||
// missing keys.
|
||||
languages.push(data.fallbackLocale);
|
||||
}
|
||||
|
||||
return languages;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { LanguageCode } from "coral-common/helpers/i18n";
|
||||
|
||||
import { RestClient } from "../lib/rest";
|
||||
|
||||
export interface InstallInput {
|
||||
@@ -8,6 +10,7 @@ export interface InstallInput {
|
||||
url: string;
|
||||
};
|
||||
allowedDomains: string[];
|
||||
locale: LanguageCode;
|
||||
};
|
||||
user: {
|
||||
username: string;
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
GQLCOMMENT_FLAG_REPORTED_REASON,
|
||||
GQLCOMMENT_SORT,
|
||||
GQLCOMMENT_STATUS,
|
||||
GQLLOCALES,
|
||||
GQLMODERATION_MODE,
|
||||
GQLMODERATION_QUEUE,
|
||||
GQLSTORY_STATUS,
|
||||
@@ -35,7 +34,6 @@ export type GQLCOMMENT_FLAG_REPORTED_REASON_RL = RelayEnumLiteral<
|
||||
>;
|
||||
export type GQLCOMMENT_SORT_RL = RelayEnumLiteral<typeof GQLCOMMENT_SORT>;
|
||||
export type GQLCOMMENT_STATUS_RL = RelayEnumLiteral<typeof GQLCOMMENT_STATUS>;
|
||||
export type GQLLOCALES_RL = RelayEnumLiteral<typeof GQLLOCALES>;
|
||||
export type GQLMODERATION_MODE_RL = RelayEnumLiteral<typeof GQLMODERATION_MODE>;
|
||||
export type GQLSTORY_STATUS_RL = RelayEnumLiteral<typeof GQLSTORY_STATUS>;
|
||||
export type GQLUSER_AUTH_CONDITIONS_RL = RelayEnumLiteral<
|
||||
|
||||
@@ -104,6 +104,7 @@ export default function createTestRenderer<
|
||||
uuidGenerator: createUUIDGenerator(),
|
||||
eventEmitter: new EventEmitter2({ wildcard: true, maxListeners: 20 }),
|
||||
clearSession: sinon.stub(),
|
||||
changeLocale: sinon.stub(),
|
||||
transitionControl: {
|
||||
allowTransition: true,
|
||||
history: [],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { Component } from "react";
|
||||
|
||||
import { LanguageCode } from "coral-common/helpers/i18n";
|
||||
import { InstallInput } from "coral-framework/rest";
|
||||
|
||||
import { InstallMutation, withInstallMutation } from "./InstallMutation";
|
||||
@@ -8,6 +9,7 @@ import CreateYourAccountStep from "./steps/CreateYourAccountStep";
|
||||
import FinalStep from "./steps/FinalStep";
|
||||
import InitialStep from "./steps/InitialStep";
|
||||
import PermittedDomainsStep from "./steps/PermittedDomainsStep";
|
||||
import SelectLanguageStep from "./steps/SelectLanguageStep";
|
||||
import Wizard from "./Wizard";
|
||||
|
||||
interface FormData {
|
||||
@@ -19,6 +21,7 @@ interface FormData {
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
allowedDomains: string[];
|
||||
locale: LanguageCode;
|
||||
}
|
||||
|
||||
interface InstallWizardState {
|
||||
@@ -35,6 +38,7 @@ function shapeFinalData(data: FormData): InstallInput {
|
||||
username,
|
||||
password,
|
||||
email,
|
||||
locale,
|
||||
} = data;
|
||||
|
||||
return {
|
||||
@@ -45,6 +49,7 @@ function shapeFinalData(data: FormData): InstallInput {
|
||||
url: organizationURL,
|
||||
},
|
||||
allowedDomains,
|
||||
locale,
|
||||
},
|
||||
user: {
|
||||
username,
|
||||
@@ -70,6 +75,7 @@ class InstallWizard extends Component<Props, InstallWizardState> {
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
allowedDomains: [],
|
||||
locale: "en-US" as LanguageCode,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -99,6 +105,12 @@ class InstallWizard extends Component<Props, InstallWizardState> {
|
||||
return (
|
||||
<Wizard currentStep={this.state.step}>
|
||||
<InitialStep onGoToNextStep={this.handleGoToNextStep} />
|
||||
<SelectLanguageStep
|
||||
data={this.state.data}
|
||||
onSaveData={this.handleSaveData}
|
||||
onGoToNextStep={this.handleGoToNextStep}
|
||||
onGoToPreviousStep={this.handleGoToPreviousStep}
|
||||
/>
|
||||
<CreateYourAccountStep
|
||||
data={this.state.data}
|
||||
onSaveData={this.handleSaveData}
|
||||
|
||||
@@ -30,6 +30,11 @@ class Wizard extends Component<WizardProps> {
|
||||
{currentStep !== 0 && currentStep !== wizardChildren.length - 1 && (
|
||||
<StepBar currentStep={currentStep - 1} className={styles.stepBar}>
|
||||
<Step hidden>Start</Step>
|
||||
<Step>
|
||||
<Localized id="install-selectLanguage-stepTitleSelect">
|
||||
<span>Select Language</span>
|
||||
</Localized>
|
||||
</Step>
|
||||
<Step>
|
||||
<Localized id="install-createYourAccount-stepTitle">
|
||||
<span>Create Admin Account</span>
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
Typography,
|
||||
} from "coral-ui/components";
|
||||
|
||||
import BackButton from "./BackButton";
|
||||
import NextButton from "./NextButton";
|
||||
|
||||
interface FormProps {
|
||||
@@ -188,8 +189,12 @@ class CreateYourAccountStep extends Component<Props> {
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Flex direction="row-reverse">
|
||||
<Flex direction="row-reverse" itemGutter>
|
||||
<NextButton submitting={submitting} />
|
||||
<BackButton
|
||||
submitting={submitting}
|
||||
onGoToPreviousStep={this.props.onGoToPreviousStep}
|
||||
/>
|
||||
</Flex>
|
||||
</HorizontalGutter>
|
||||
</form>
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { FunctionComponent, useCallback } from "react";
|
||||
import { Field, Form } from "react-final-form";
|
||||
|
||||
import { LanguageCode } from "coral-common/helpers/i18n";
|
||||
import { LocaleField } from "coral-framework/components";
|
||||
import { useCoralContext } from "coral-framework/lib/bootstrap";
|
||||
import { OnSubmit, ValidationMessage } from "coral-framework/lib/form";
|
||||
import { required } from "coral-framework/lib/validation";
|
||||
import {
|
||||
CallOut,
|
||||
Flex,
|
||||
FormField,
|
||||
HorizontalGutter,
|
||||
InputLabel,
|
||||
Typography,
|
||||
} from "coral-ui/components";
|
||||
|
||||
import NextButton from "./NextButton";
|
||||
|
||||
interface FormProps {
|
||||
locale: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onGoToNextStep: () => void;
|
||||
onGoToPreviousStep: () => void;
|
||||
data: FormProps;
|
||||
onSaveData: (newData: FormProps) => void;
|
||||
}
|
||||
|
||||
const SelectLanguageStep: FunctionComponent<Props> = props => {
|
||||
const { changeLocale } = useCoralContext();
|
||||
const onSubmit = useCallback<OnSubmit<FormProps>>(
|
||||
async (input, form) => {
|
||||
props.onSaveData(input);
|
||||
await changeLocale(input.locale as LanguageCode);
|
||||
return props.onGoToNextStep();
|
||||
},
|
||||
[changeLocale, props.onSaveData, props.onGoToNextStep]
|
||||
);
|
||||
|
||||
return (
|
||||
<Form
|
||||
onSubmit={onSubmit}
|
||||
initialValues={{
|
||||
locale: props.data.locale,
|
||||
}}
|
||||
>
|
||||
{({ handleSubmit, submitting, submitError }) => (
|
||||
<form autoComplete="off" onSubmit={handleSubmit}>
|
||||
<HorizontalGutter size="double">
|
||||
<Localized id="install-selectLanguage-selectLanguage">
|
||||
<Typography variant="heading1" align="center">
|
||||
Select language for Coral
|
||||
</Typography>
|
||||
</Localized>
|
||||
|
||||
<Localized id="install-selectLanguage-description">
|
||||
<Typography variant="bodyCopy" align="center">
|
||||
Choose the language to be used during the installation process.
|
||||
This will also be the default language for your Coral community.
|
||||
</Typography>
|
||||
</Localized>
|
||||
|
||||
{submitError && (
|
||||
<CallOut color="error" fullWidth>
|
||||
{submitError}
|
||||
</CallOut>
|
||||
)}
|
||||
|
||||
<Field name="locale" validate={required}>
|
||||
{({ input, meta }) => (
|
||||
<FormField>
|
||||
<Localized id="install-selectLanguage-language">
|
||||
<InputLabel>Language</InputLabel>
|
||||
</Localized>
|
||||
<LocaleField disabled={submitting} fullWidth {...input} />
|
||||
<ValidationMessage meta={meta} fullWidth />
|
||||
</FormField>
|
||||
)}
|
||||
</Field>
|
||||
<Flex direction="row-reverse">
|
||||
<NextButton submitting={submitting} />
|
||||
</Flex>
|
||||
</HorizontalGutter>
|
||||
</form>
|
||||
)}
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectLanguageStep;
|
||||
@@ -12,7 +12,6 @@ import "coral-ui/theme/variables.css";
|
||||
async function main() {
|
||||
const ManagedCoralContextProvider = await createManaged({
|
||||
localesData,
|
||||
userLocales: navigator.languages,
|
||||
});
|
||||
|
||||
const Index: FunctionComponent = () => (
|
||||
|
||||
@@ -15,7 +15,6 @@ async function main() {
|
||||
const ManagedCoralContextProvider = await createManaged({
|
||||
initLocalState,
|
||||
localesData,
|
||||
userLocales: navigator.languages,
|
||||
pym: new PymChild({
|
||||
polling: 100,
|
||||
}),
|
||||
|
||||
@@ -54,8 +54,11 @@ const SelectField: FunctionComponent<SelectFieldProps> = props => {
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const selectClassName = cn(classes.select, {
|
||||
const rootClassName = cn(classes.root, className, {
|
||||
[classes.fullWidth]: fullWidth,
|
||||
});
|
||||
|
||||
const selectClassName = cn(classes.select, {
|
||||
[classes.keyboardFocus]: keyboardFocus,
|
||||
});
|
||||
|
||||
@@ -64,7 +67,7 @@ const SelectField: FunctionComponent<SelectFieldProps> = props => {
|
||||
});
|
||||
|
||||
return (
|
||||
<span className={cn(classes.root, className)}>
|
||||
<span className={rootClassName}>
|
||||
<select className={selectClassName} disabled={disabled} {...rest}>
|
||||
{children}
|
||||
</select>
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
exports[`renders correctly 1`] = `
|
||||
<span
|
||||
className="SelectField-root customClassName"
|
||||
className="SelectField-root customClassName SelectField-fullWidth"
|
||||
>
|
||||
<select
|
||||
autofocus={true}
|
||||
className="SelectField-select SelectField-fullWidth"
|
||||
className="SelectField-select"
|
||||
disabled={true}
|
||||
id="selectID"
|
||||
name="selectName"
|
||||
|
||||
@@ -6,9 +6,7 @@
|
||||
url("material-design-icons/iconfont/MaterialIcons-Regular.woff2")
|
||||
format("woff2"),
|
||||
url("material-design-icons/iconfont/MaterialIcons-Regular.woff")
|
||||
format("woff"),
|
||||
url("material-design-icons/iconfont/MaterialIcons-Regular.ttf")
|
||||
format("truetype");
|
||||
format("woff");
|
||||
}
|
||||
|
||||
.icon {
|
||||
|
||||
@@ -4,15 +4,23 @@
|
||||
*/
|
||||
export type LanguageCode = "en-US" | "pt-BR" | "es" | "de" | "nl-NL" | "da";
|
||||
|
||||
/**
|
||||
* LOCALES_MAP contains a map of language codes associated with their
|
||||
* name in native language.
|
||||
*/
|
||||
export const LOCALES_MAP: Record<LanguageCode, string> = {
|
||||
"en-US": "English",
|
||||
"pt-BR": "Português brasileiro",
|
||||
es: "Español",
|
||||
de: "Deutsch",
|
||||
"nl-NL": "Nederlands",
|
||||
da: "Dansk",
|
||||
};
|
||||
|
||||
/**
|
||||
* LOCALES is an array of supported language codes that can be accessed as a
|
||||
* value.
|
||||
*/
|
||||
export const LOCALES: LanguageCode[] = [
|
||||
"en-US",
|
||||
"pt-BR",
|
||||
"es",
|
||||
"de",
|
||||
"nl-NL",
|
||||
"da",
|
||||
];
|
||||
export const LOCALES: LanguageCode[] = Object.keys(
|
||||
LOCALES_MAP
|
||||
) as LanguageCode[];
|
||||
|
||||
@@ -54,13 +54,14 @@ const TenantInstallBodySchema = Joi.object().keys({
|
||||
|
||||
export type TenantInstallHandlerOptions = Pick<
|
||||
AppOptions,
|
||||
"redis" | "mongo" | "config" | "mailerQueue"
|
||||
"redis" | "mongo" | "config" | "mailerQueue" | "i18n"
|
||||
>;
|
||||
|
||||
export const installHandler = ({
|
||||
mongo,
|
||||
redis,
|
||||
config,
|
||||
i18n,
|
||||
}: TenantInstallHandlerOptions): RequestHandler => async (req, res, next) => {
|
||||
try {
|
||||
if (!req.coral) {
|
||||
@@ -95,6 +96,7 @@ export const installHandler = ({
|
||||
mongo,
|
||||
redis,
|
||||
req.coral.cache.tenant,
|
||||
i18n,
|
||||
{
|
||||
...tenantInput,
|
||||
// Infer the Tenant domain via the hostname parameter.
|
||||
|
||||
@@ -2,16 +2,20 @@ import express, { Router } from "express";
|
||||
import { minify } from "html-minifier";
|
||||
import path from "path";
|
||||
|
||||
import { LanguageCode } from "coral-common/helpers/i18n/locales";
|
||||
import { cacheHeadersMiddleware } from "coral-server/app/middleware/cacheHeaders";
|
||||
import { cspTenantMiddleware } from "coral-server/app/middleware/csp/tenant";
|
||||
import { installedMiddleware } from "coral-server/app/middleware/installed";
|
||||
import { tenantMiddleware } from "coral-server/app/middleware/tenant";
|
||||
import logger from "coral-server/logger";
|
||||
import TenantCache from "coral-server/services/tenant/cache";
|
||||
import { RequestHandler } from "coral-server/types/express";
|
||||
|
||||
import Entrypoints, { Entrypoint } from "../helpers/entrypoints";
|
||||
|
||||
export interface ClientTargetHandlerOptions {
|
||||
defaultLocale: LanguageCode;
|
||||
|
||||
/**
|
||||
* entrypoint is the entrypoint entry to load.
|
||||
*/
|
||||
@@ -35,50 +39,64 @@ export interface ClientTargetHandlerOptions {
|
||||
staticURI: string;
|
||||
}
|
||||
|
||||
function createClientTargetRouter({
|
||||
staticURI,
|
||||
entrypoint,
|
||||
enableCustomCSS = false,
|
||||
cacheDuration = "1h",
|
||||
}: ClientTargetHandlerOptions) {
|
||||
function createClientTargetRouter(options: ClientTargetHandlerOptions) {
|
||||
// Create a router.
|
||||
const router = express.Router();
|
||||
|
||||
// Always send the cache headers.
|
||||
router.use(cacheHeadersMiddleware(cacheDuration));
|
||||
router.use(cacheHeadersMiddleware(options.cacheDuration));
|
||||
|
||||
// Wildcard display all the client routes under the provided prefix.
|
||||
router.get("/*", (req, res, next) =>
|
||||
res.render(
|
||||
"client",
|
||||
{ staticURI, entrypoint, enableCustomCSS },
|
||||
(err, html) => {
|
||||
if (err) {
|
||||
return next(err);
|
||||
}
|
||||
|
||||
// Send back the HTML minified.
|
||||
res.send(
|
||||
minify(html, {
|
||||
removeComments: true,
|
||||
collapseWhitespace: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
||||
router.get("/*", clientHandler(options));
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
interface MountClientRouteOptions {
|
||||
defaultLocale: LanguageCode;
|
||||
tenantCache: TenantCache;
|
||||
staticURI: string;
|
||||
}
|
||||
|
||||
const clientHandler = ({
|
||||
staticURI,
|
||||
entrypoint,
|
||||
enableCustomCSS,
|
||||
defaultLocale,
|
||||
}: ClientTargetHandlerOptions): RequestHandler => (req, res, next) => {
|
||||
// Provide configuration to the frontend in the HTML.
|
||||
const config = {
|
||||
staticURI,
|
||||
};
|
||||
|
||||
// Grab the locale code from the tenant configuration, if available.
|
||||
let locale: LanguageCode = defaultLocale;
|
||||
if (req.coral && req.coral.tenant) {
|
||||
locale = req.coral.tenant.locale;
|
||||
}
|
||||
|
||||
res.render(
|
||||
"client",
|
||||
{ staticURI, entrypoint, enableCustomCSS, locale, config },
|
||||
(err, html) => {
|
||||
if (err) {
|
||||
return next(err);
|
||||
}
|
||||
|
||||
// Send back the HTML minified.
|
||||
res.send(
|
||||
minify(html, {
|
||||
removeComments: true,
|
||||
collapseWhitespace: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export function mountClientRoutes(
|
||||
router: Router,
|
||||
{ staticURI, tenantCache }: MountClientRouteOptions
|
||||
{ staticURI, tenantCache, defaultLocale }: MountClientRouteOptions
|
||||
) {
|
||||
// TODO: (wyattjoh) figure out a better way of referencing paths.
|
||||
// Load the entrypoint manifest.
|
||||
@@ -119,6 +137,7 @@ export function mountClientRoutes(
|
||||
staticURI,
|
||||
enableCustomCSS: true,
|
||||
entrypoint: entrypoints.get("stream"),
|
||||
defaultLocale,
|
||||
})
|
||||
);
|
||||
router.use(
|
||||
@@ -127,6 +146,7 @@ export function mountClientRoutes(
|
||||
staticURI,
|
||||
cacheDuration: false,
|
||||
entrypoint: entrypoints.get("authCallback"),
|
||||
defaultLocale,
|
||||
})
|
||||
);
|
||||
router.use(
|
||||
@@ -135,6 +155,7 @@ export function mountClientRoutes(
|
||||
staticURI,
|
||||
cacheDuration: false,
|
||||
entrypoint: entrypoints.get("auth"),
|
||||
defaultLocale,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -147,6 +168,7 @@ export function mountClientRoutes(
|
||||
staticURI,
|
||||
cacheDuration: false,
|
||||
entrypoint: entrypoints.get("account"),
|
||||
defaultLocale,
|
||||
})
|
||||
);
|
||||
// Add the standalone targets.
|
||||
@@ -158,6 +180,7 @@ export function mountClientRoutes(
|
||||
staticURI,
|
||||
cacheDuration: false,
|
||||
entrypoint: entrypoints.get("admin"),
|
||||
defaultLocale,
|
||||
})
|
||||
);
|
||||
router.use(
|
||||
@@ -171,6 +194,7 @@ export function mountClientRoutes(
|
||||
staticURI,
|
||||
cacheDuration: false,
|
||||
entrypoint: entrypoints.get("install"),
|
||||
defaultLocale,
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import cookies from "cookie-parser";
|
||||
import express, { Router } from "express";
|
||||
import { register } from "prom-client";
|
||||
|
||||
import { LanguageCode } from "coral-common/helpers/i18n/locales";
|
||||
import { AppOptions } from "coral-server/app";
|
||||
import { noCacheMiddleware } from "coral-server/app/middleware/cacheHeaders";
|
||||
import playground from "coral-server/app/middleware/playground";
|
||||
@@ -31,6 +32,7 @@ export function createRouter(app: AppOptions, options: RouterOptions) {
|
||||
|
||||
if (!options.disableClientRoutes) {
|
||||
mountClientRoutes(router, {
|
||||
defaultLocale: app.config.get("default_locale") as LanguageCode,
|
||||
// When mounting client routes, we need to provide a staticURI even when
|
||||
// not provided to the default current domain relative "/".
|
||||
staticURI: app.config.get("static_uri") || "/",
|
||||
|
||||
@@ -4,8 +4,24 @@
|
||||
{% block title %}Coral{% endblock %}
|
||||
|
||||
{% block meta %}
|
||||
<script type="application/javascript" id="config">
|
||||
{{ staticURI | dump | safe }}
|
||||
{# Insert the link preload tags here. #}
|
||||
{% if entrypoint.css %}
|
||||
{% for asset in entrypoint.css %}
|
||||
{{ macros.preload(asset.src, "style", prefix = staticURI) }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if enableCustomCSS and tenant and tenant.customCSSURL %}
|
||||
{{ macros.preload(tenant.customCSSURL, "style", crossorigin = true) }}
|
||||
{% endif %}
|
||||
{% if entrypoint.js %}
|
||||
{% for asset in entrypoint.js %}
|
||||
{{ macros.preload(asset.src, "script", prefix = staticURI) }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{# Insert the staticURI via the configuration object during insertion. #}
|
||||
<script type="application/json" id="config">
|
||||
{{ config | dump | safe }}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -18,9 +34,11 @@
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{# Custom CSS is included after the CSS block so that its overrides will apply #}
|
||||
{% if enableCustomCSS %}
|
||||
{# Custom CSS is included after the CSS block so that its overrides will apply #}
|
||||
{% include "partials/customCSS.html" %}
|
||||
{% if tenant and tenant.customCSSURL %}
|
||||
{{ macros.css(tenant.customCSSURL) }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% endif %}
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
{% macro css(src, integrity = '', prefix = '') %}
|
||||
{% macro preload(src, htmlAs, prefix = "", crossorigin = false) %}
|
||||
{% if crossorigin %}
|
||||
<link rel="preload" href="{{ prefix }}{{ src }}" as="{{ htmlAs }}" crossorigin>
|
||||
{% else %}
|
||||
<link rel="preload" href="{{ prefix }}{{ src }}" as="{{ htmlAs }}">
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro css(src, integrity = "", prefix = "") %}
|
||||
<link type="text/css" rel="stylesheet" href="{{ prefix }}{{ src }}"/>
|
||||
{# TODO: evaluate when to enable SRI, non-SSL connections cause issues #}
|
||||
{# {% if integrity %}
|
||||
@@ -8,7 +16,7 @@
|
||||
{% endif %} #}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro js(src, integrity = '', prefix = '') %}
|
||||
{% macro js(src, integrity = "", prefix = "") %}
|
||||
<script type="application/javascript" src="{{ prefix }}{{ src }}"></script>
|
||||
{# TODO: evaluate when to enable SRI, non-SSL connections cause issues #}
|
||||
{# {% if false %}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{% import "../macros.html" as macros %}
|
||||
|
||||
{% if tenant and tenant.customCSSURL %}
|
||||
{{ macros.css(tenant.customCSSURL) }}
|
||||
{% endif %}
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<html lang="{{ locale | default('en-US', true) }}">
|
||||
<head>
|
||||
{# Meta tags #}
|
||||
<meta charset="utf-8" />
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Kind } from "graphql";
|
||||
|
||||
import Locale from "./locale";
|
||||
|
||||
describe("parseLiteral", () => {
|
||||
it("parses a valid locale from a string", () => {
|
||||
expect(
|
||||
Locale.parseLiteral({
|
||||
kind: Kind.STRING,
|
||||
value: "en-US",
|
||||
})
|
||||
).toBe("en-US");
|
||||
});
|
||||
it("parses an unsupported locale from a string", () => {
|
||||
expect(() =>
|
||||
Locale.parseLiteral({
|
||||
kind: Kind.STRING,
|
||||
value: "xyz",
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("throws when not a string", () => {
|
||||
expect(() =>
|
||||
Locale.parseLiteral({
|
||||
kind: Kind.INT,
|
||||
value: "4",
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseValue", () => {
|
||||
it("parses a valid locale from a string", () => {
|
||||
expect(Locale.parseValue("en-US")).toBe("en-US");
|
||||
});
|
||||
it("parses an unsupported locale from a string", () => {
|
||||
expect(() => Locale.parseValue("xyz")).toThrow();
|
||||
});
|
||||
it("throws when not a string", () => {
|
||||
expect(() => Locale.parseValue(4)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { LOCALES } from "coral-common/helpers/i18n";
|
||||
import { GraphQLScalarType } from "graphql";
|
||||
import { Kind } from "graphql/language";
|
||||
|
||||
function assertSupportLocale(locale: string) {
|
||||
if (!LOCALES.includes(locale as any)) {
|
||||
throw new Error(`Supported locales are ${JSON.stringify(LOCALES)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new GraphQLScalarType({
|
||||
name: "Locale",
|
||||
description: "Locale represents a language code in the BCP 47 format.",
|
||||
serialize(value) {
|
||||
return value;
|
||||
},
|
||||
parseValue(value) {
|
||||
if (typeof value !== "string") {
|
||||
throw new Error("Locale must be a string in BCP 47 format.");
|
||||
}
|
||||
assertSupportLocale(value);
|
||||
return value;
|
||||
},
|
||||
parseLiteral(ast) {
|
||||
if (ast.kind !== Kind.STRING) {
|
||||
throw new Error("Locale must be a string in BCP 47 format.");
|
||||
}
|
||||
const value = ast.value.toString();
|
||||
assertSupportLocale(value);
|
||||
return value;
|
||||
},
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
import { LanguageCode } from "coral-common/helpers/i18n/locales";
|
||||
|
||||
import { GQLLOCALES } from "../schema/__generated__/types";
|
||||
import { LOCALES } from "./LOCALES";
|
||||
|
||||
it("does not contain duplicate entries", () => {
|
||||
const seen: Partial<Record<LanguageCode, true>> = {};
|
||||
for (const key in LOCALES) {
|
||||
if (!LOCALES.hasOwnProperty(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = LOCALES[key as GQLLOCALES];
|
||||
expect(value in seen).toBeFalsy();
|
||||
seen[value] = true;
|
||||
}
|
||||
});
|
||||
|
||||
it("contains the correct mappings to the BCP 47 format", () => {
|
||||
for (const key in LOCALES) {
|
||||
if (!LOCALES.hasOwnProperty(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = LOCALES[key as GQLLOCALES];
|
||||
expect(value).toEqual(key.replace(/_/, "-"));
|
||||
}
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
import { LanguageCode } from "coral-common/helpers/i18n/locales";
|
||||
|
||||
import { GQLLOCALES } from "../schema/__generated__/types";
|
||||
|
||||
export const LOCALES: Record<GQLLOCALES, LanguageCode> = {
|
||||
en_US: "en-US",
|
||||
pt_BR: "pt-BR",
|
||||
es: "es",
|
||||
de: "de",
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import Cursor from "coral-server/graph/common/scalars/cursor";
|
||||
import Locale from "coral-server/graph/common/scalars/locale";
|
||||
import Time from "coral-server/graph/common/scalars/time";
|
||||
import { GQLResolver } from "coral-server/graph/tenant/schema/__generated__/types";
|
||||
|
||||
@@ -81,6 +82,7 @@ const Resolvers: GQLResolver = {
|
||||
UsernameHistory,
|
||||
Tag,
|
||||
Time,
|
||||
Locale,
|
||||
User,
|
||||
UserStatus,
|
||||
UsernameStatus,
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import {
|
||||
addResolveFunctionsToSchema,
|
||||
attachDirectiveResolvers,
|
||||
IEnumResolver,
|
||||
IResolvers,
|
||||
} from "graphql-tools";
|
||||
import { attachDirectiveResolvers, IResolvers } from "graphql-tools";
|
||||
|
||||
import { loadSchema } from "coral-common/graphql";
|
||||
import auth from "coral-server/graph/common/directives/auth";
|
||||
import resolvers from "coral-server/graph/tenant/resolvers";
|
||||
import { LOCALES } from "coral-server/graph/tenant/resolvers/LOCALES";
|
||||
|
||||
export default function getTenantSchema() {
|
||||
const schema = loadSchema("tenant", resolvers as IResolvers);
|
||||
@@ -16,15 +10,5 @@ export default function getTenantSchema() {
|
||||
// Attach the directive resolvers.
|
||||
attachDirectiveResolvers(schema, { auth });
|
||||
|
||||
// Attach the GraphQL enum fields.
|
||||
addResolveFunctionsToSchema({
|
||||
schema,
|
||||
resolvers: {
|
||||
// For some reason, the resolver doesn't quite work without coercing the
|
||||
// type.
|
||||
LOCALES: LOCALES as IEnumResolver,
|
||||
},
|
||||
});
|
||||
|
||||
return schema;
|
||||
}
|
||||
|
||||
@@ -68,6 +68,11 @@ Cursor represents a paginating cursor.
|
||||
"""
|
||||
scalar Cursor
|
||||
|
||||
"""
|
||||
Locale represents a language code in the BCP 47 format.
|
||||
"""
|
||||
scalar Locale
|
||||
|
||||
################################################################################
|
||||
## Actions
|
||||
################################################################################
|
||||
@@ -990,17 +995,6 @@ type StoryMessageBox {
|
||||
## Settings
|
||||
################################################################################
|
||||
|
||||
"""
|
||||
LOCALES list all the supported locales in a modified BCP 47 format, where the
|
||||
hyphen is replaced by an underscore.
|
||||
"""
|
||||
enum LOCALES {
|
||||
en_US
|
||||
pt_BR
|
||||
es
|
||||
de
|
||||
}
|
||||
|
||||
"""
|
||||
CloseCommenting contains settings related to the automatic closing of commenting
|
||||
on Stories.
|
||||
@@ -1124,7 +1118,7 @@ type Settings {
|
||||
"""
|
||||
locale is the specified locale for this Tenant.
|
||||
"""
|
||||
locale: LOCALES!
|
||||
locale: Locale!
|
||||
|
||||
"""
|
||||
live provides configuration options related to live updates for stories on
|
||||
@@ -3371,6 +3365,11 @@ input SettingsInput {
|
||||
accountFeatures specifies the configuration for accounts.
|
||||
"""
|
||||
accountFeatures: CommenterAccountFeaturesInput
|
||||
|
||||
"""
|
||||
locale specifies the locale for this Tenant.
|
||||
"""
|
||||
locale: Locale
|
||||
}
|
||||
|
||||
"""
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
closeCommentingDefaultMessage = Comments are closed on this story.
|
||||
disableCommentingDefaultMessage = Comments are closed on this story.
|
||||
|
||||
reaction-labelRespect = Respect
|
||||
reaction-labelActiveRespected = Respected
|
||||
reaction-sortLabelMostRespected = Most Respected
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import crypto from "crypto";
|
||||
import { FluentBundle } from "fluent/compat";
|
||||
|
||||
import { GQLReactionConfiguration } from "coral-server/graph/tenant/schema/__generated__/types";
|
||||
import { translate } from "coral-server/services/i18n";
|
||||
|
||||
export const getDefaultReactionConfiguration = (
|
||||
bundle: FluentBundle
|
||||
): GQLReactionConfiguration => ({
|
||||
// By default, the standard reaction style will use the Respect with the
|
||||
// handshake.
|
||||
label: translate(bundle, "Respect", "reaction-labelRespect"),
|
||||
labelActive: translate(bundle, "Respected", "reaction-labelActiveRespected"),
|
||||
sortLabel: translate(
|
||||
bundle,
|
||||
"Most Respected",
|
||||
"reaction-sortLabelMostRespected"
|
||||
),
|
||||
icon: "thumb_up",
|
||||
});
|
||||
|
||||
export function generateSSOKey() {
|
||||
// Generate a new key. We generate a key of minimum length 32 up to 37 bytes,
|
||||
// as 16 was the minimum length recommended.
|
||||
//
|
||||
// Reference: https://security.stackexchange.com/a/96176
|
||||
return crypto.randomBytes(32 + Math.floor(Math.random() * 5)).toString("hex");
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./tenant";
|
||||
export * from "./helpers";
|
||||
@@ -1,4 +1,3 @@
|
||||
import crypto from "crypto";
|
||||
import { Db } from "mongodb";
|
||||
import uuid from "uuid";
|
||||
|
||||
@@ -14,6 +13,8 @@ import {
|
||||
createIndexFactory,
|
||||
} from "coral-server/models/helpers";
|
||||
import { Settings } from "coral-server/models/settings";
|
||||
import { I18n } from "coral-server/services/i18n";
|
||||
import { generateSSOKey, getDefaultReactionConfiguration } from "./helpers";
|
||||
|
||||
const collection = createCollection<Tenant>("tenants");
|
||||
|
||||
@@ -72,6 +73,7 @@ export type CreateTenantInput = Pick<
|
||||
*/
|
||||
export async function createTenant(
|
||||
mongo: Db,
|
||||
i18n: I18n,
|
||||
input: CreateTenantInput,
|
||||
now = new Date()
|
||||
) {
|
||||
@@ -178,14 +180,7 @@ export async function createTenant(
|
||||
doNotStore: true,
|
||||
},
|
||||
},
|
||||
reaction: {
|
||||
// By default, the standard reaction style will use the Respect with the
|
||||
// handshake.
|
||||
label: "Respect",
|
||||
labelActive: "Respected",
|
||||
sortLabel: "Most Respected",
|
||||
icon: "thumb_up",
|
||||
},
|
||||
reaction: getDefaultReactionConfiguration(i18n.getBundle(input.locale)),
|
||||
stories: {
|
||||
scraping: {
|
||||
enabled: true,
|
||||
@@ -281,14 +276,6 @@ export async function updateTenant(
|
||||
return result.value || null;
|
||||
}
|
||||
|
||||
function generateSSOKey() {
|
||||
// Generate a new key. We generate a key of minimum length 32 up to 37 bytes,
|
||||
// as 16 was the minimum length recommended.
|
||||
//
|
||||
// Reference: https://security.stackexchange.com/a/96176
|
||||
return crypto.randomBytes(32 + Math.floor(Math.random() * 5)).toString("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* regenerateTenantSSOKey will regenerate the SSO key used for Single Sing-On
|
||||
* for the specified Tenant. All existing user sessions signed with the old
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Tenant,
|
||||
updateTenant,
|
||||
} from "coral-server/models/tenant";
|
||||
import { I18n } from "coral-server/services/i18n";
|
||||
|
||||
import TenantCache from "./cache";
|
||||
|
||||
@@ -55,6 +56,7 @@ export async function install(
|
||||
mongo: Db,
|
||||
redis: Redis,
|
||||
cache: TenantCache,
|
||||
i18n: I18n,
|
||||
input: InstallTenant,
|
||||
now = new Date()
|
||||
) {
|
||||
@@ -64,12 +66,10 @@ export async function install(
|
||||
|
||||
// TODO: (wyattjoh) perform any pending migrations.
|
||||
|
||||
// TODO: (wyattjoh) setup database indexes.
|
||||
|
||||
logger.info({ tenant: input }, "installing tenant");
|
||||
|
||||
// Create the Tenant.
|
||||
const tenant = await createTenant(mongo, input, now);
|
||||
const tenant = await createTenant(mongo, i18n, input, now);
|
||||
|
||||
// Update the tenant cache.
|
||||
await cache.update(redis, tenant);
|
||||
|
||||
@@ -93,7 +93,11 @@ configure-general-guidelines-explanation =
|
||||
Markdown can be found <externalLink>here</externalLink>.
|
||||
configure-general-guidelines-showCommunityGuidelines = Show Community Guidelines Summary
|
||||
|
||||
### Sitewide Commenting
|
||||
#### Locale
|
||||
configure-general-locale-language = Language
|
||||
configure-general-locale-chooseLanguage = Choose the language for your Coral community.
|
||||
|
||||
#### Sitewide Commenting
|
||||
configure-general-sitewideCommenting-title = Sitewide Commenting
|
||||
configure-general-sitewideCommenting-explanation =
|
||||
Open or close comment streams for new comments sitewide. When new comments
|
||||
@@ -110,7 +114,7 @@ configure-general-sitewideCommenting-message = Sitewide Closed Comments Message
|
||||
configure-general-sitewideCommenting-messageExplanation =
|
||||
Write a message that will be displayed when comment streams are closed sitewide
|
||||
|
||||
### Closing Comment Streams
|
||||
#### Closing Comment Streams
|
||||
configure-general-closingCommentStreams-title = Closing Comment Streams
|
||||
configure-general-closingCommentStreams-explanation = Set comment streams to close after a defined period of time after a story’s publication
|
||||
configure-general-closingCommentStreams-closeCommentsAutomatically = Close Comments Automatically
|
||||
@@ -747,7 +751,7 @@ configure-general-reactions-preview = Preview
|
||||
configure-general-reaction-sortMenu-sortBy = Sort by
|
||||
|
||||
configure-account-features-title = Commenter account mangement features
|
||||
configure-account-features-explanation =
|
||||
configure-account-features-explanation =
|
||||
You can enable and disable certain features for your commenters to use
|
||||
within their Profile. These features also assist towards GDPR
|
||||
compliance.
|
||||
@@ -759,11 +763,11 @@ configure-account-features-no = No
|
||||
configure-account-features-download-comments = Download their comments
|
||||
configure-account-features-download-comments-details = Commenters can download a csv of their comment history.
|
||||
configure-account-features-delete-account = Delete their account
|
||||
configure-account-features-delete-account-details =
|
||||
configure-account-features-delete-account-details =
|
||||
Removes all of their comment data, username, and email address from the site and the database.
|
||||
|
||||
configure-advanced-stories = Story creation
|
||||
configure-advanced-stories-explanation = Advanced settings for how stories are created within Coral.
|
||||
configure-advanced-stories-explanation = Advanced settings for how stories are created within Coral.
|
||||
configure-advanced-stories-lazy = Lazy story creation
|
||||
configure-advanced-stories-lazy-detail = Enable stories to be automatically created when they are published from your CMS.
|
||||
configure-advanced-stories-scraping = Story scraping
|
||||
@@ -772,4 +776,4 @@ configure-advanced-stories-proxy = Scraper proxy url
|
||||
configure-advanced-stories-proxy-detail =
|
||||
When specified, allows scraping requests to use the provided
|
||||
proxy. All requests will then be passed through the appropriote
|
||||
proxy as parsed by the <externalLink>npm proxy-agent</externalLink> package.
|
||||
proxy as parsed by the <externalLink>npm proxy-agent</externalLink> package.
|
||||
|
||||
@@ -8,6 +8,13 @@ install-header-title = { -product-name } Installation Wizard
|
||||
install-initialStep-theRemainder = The remainder of the installation wizard will take about 10 minutes. Once you are finished, you will have your own instance of { -product-name }.
|
||||
install-initialStep-getStarted = Get Started
|
||||
|
||||
install-selectLanguage-stepTitleSelect = Select Language
|
||||
install-selectLanguage-selectLanguage = Select language for Coral
|
||||
install-selectLanguage-description =
|
||||
Choose the language to be used during the installation process.
|
||||
This will also be the default language for your Coral community.
|
||||
install-selectLanguage-language = Language
|
||||
|
||||
install-addOrganization-stepTitle = Add Organization Details
|
||||
install-addOrganization-title = Add Organization
|
||||
install-addOrganization-description = Please tell us the name of your organization. This will appear in emails when inviting new team members.
|
||||
|
||||
Reference in New Issue
Block a user