Effort toward reconsiliating the server templating and the react world
This commit is contained in:
parent
9e21b5cb93
commit
f8bf54835d
@ -1,7 +1,11 @@
|
|||||||
import { useReducer, useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { clsx } from "keycloakify/tools/clsx";
|
|
||||||
import { assert } from "tsafe/assert";
|
import { assert } from "tsafe/assert";
|
||||||
import { useInsertScriptTags, type ScriptTag } from "keycloakify/tools/useInsertScriptTags";
|
import { createUseInsertScriptTags, type ScriptTag } from "keycloakify/tools/useInsertScriptTags";
|
||||||
|
import { createUseInsertLinkTags } from "keycloakify/tools/useInsertLinkTags";
|
||||||
|
import { useSetClassName } from "keycloakify/tools/useSetClassName";
|
||||||
|
|
||||||
|
const { useInsertLinkTags } = createUseInsertLinkTags();
|
||||||
|
const { useInsertScriptTags } = createUseInsertScriptTags();
|
||||||
|
|
||||||
export function usePrepareTemplate(params: {
|
export function usePrepareTemplate(params: {
|
||||||
styleSheetHrefs: string[];
|
styleSheetHrefs: string[];
|
||||||
@ -33,88 +37,25 @@ export function usePrepareTemplate(params: {
|
|||||||
|
|
||||||
const { areAllStyleSheetsLoaded } = useInsertLinkTags({ "hrefs": styleSheetHrefs });
|
const { areAllStyleSheetsLoaded } = useInsertLinkTags({ "hrefs": styleSheetHrefs });
|
||||||
|
|
||||||
// NOTE: We want to load the script after the page have been fully rendered.
|
const { insertScriptTags } = useInsertScriptTags({ scriptTags });
|
||||||
useInsertScriptTags({ "scriptTags": !areAllStyleSheetsLoaded ? [] : scriptTags });
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!areAllStyleSheetsLoaded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
insertScriptTags();
|
||||||
|
}, [areAllStyleSheetsLoaded]);
|
||||||
|
|
||||||
useSetClassName({
|
useSetClassName({
|
||||||
"target": "html",
|
"qualifiedName": "html",
|
||||||
"className": htmlClassName
|
"className": htmlClassName
|
||||||
});
|
});
|
||||||
|
|
||||||
useSetClassName({
|
useSetClassName({
|
||||||
"target": "body",
|
"qualifiedName": "body",
|
||||||
"className": bodyClassName
|
"className": bodyClassName
|
||||||
});
|
});
|
||||||
|
|
||||||
return { areAllStyleSheetsLoaded };
|
return { areAllStyleSheetsLoaded };
|
||||||
}
|
}
|
||||||
|
|
||||||
function useSetClassName(params: { target: "html" | "body"; className: string | undefined }) {
|
|
||||||
const { target, className } = params;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (className === undefined) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const htmlClassList = document.getElementsByTagName(target)[0].classList;
|
|
||||||
|
|
||||||
const tokens = clsx(className).split(" ");
|
|
||||||
|
|
||||||
htmlClassList.add(...tokens);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
htmlClassList.remove(...tokens);
|
|
||||||
};
|
|
||||||
}, [className]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const hrefByPrLoaded = new Map<string, Promise<void>>();
|
|
||||||
|
|
||||||
/** NOTE: The hrefs can't changes. There should be only one one call on this. */
|
|
||||||
function useInsertLinkTags(params: { hrefs: string[] }) {
|
|
||||||
const { hrefs } = params;
|
|
||||||
|
|
||||||
const [areAllStyleSheetsLoaded, setAllStyleSheetLoaded] = useReducer(() => true, hrefs.length === 0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let isActive = true;
|
|
||||||
|
|
||||||
let lastMountedHtmlElement: HTMLLinkElement | undefined = undefined;
|
|
||||||
|
|
||||||
for (const href of hrefs) {
|
|
||||||
if (hrefByPrLoaded.has(href)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const htmlElement = document.createElement("link");
|
|
||||||
|
|
||||||
hrefByPrLoaded.set(href, new Promise<void>(resolve => htmlElement.addEventListener("load", () => resolve())));
|
|
||||||
|
|
||||||
htmlElement.rel = "stylesheet";
|
|
||||||
|
|
||||||
htmlElement.href = href;
|
|
||||||
|
|
||||||
if (lastMountedHtmlElement !== undefined) {
|
|
||||||
lastMountedHtmlElement.insertAdjacentElement("afterend", htmlElement);
|
|
||||||
} else {
|
|
||||||
document.head.prepend(htmlElement);
|
|
||||||
}
|
|
||||||
|
|
||||||
lastMountedHtmlElement = htmlElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
Promise.all(Array.from(hrefByPrLoaded.values())).then(() => {
|
|
||||||
if (!isActive) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setAllStyleSheetLoaded();
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isActive = false;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { areAllStyleSheetsLoaded };
|
|
||||||
}
|
|
||||||
|
@ -28,8 +28,8 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
|
|||||||
|
|
||||||
const { realm, locale, auth, url, message, isAppInitiatedAction, authenticationSession, scripts } = kcContext;
|
const { realm, locale, auth, url, message, isAppInitiatedAction, authenticationSession, scripts } = kcContext;
|
||||||
|
|
||||||
const { isReady } = usePrepareTemplate({
|
const { areAllStyleSheetsLoaded } = usePrepareTemplate({
|
||||||
"styles": !doUseDefaultCss
|
"styleSheetHrefs": !doUseDefaultCss
|
||||||
? []
|
? []
|
||||||
: [
|
: [
|
||||||
`${url.resourcesCommonPath}/node_modules/@patternfly/patternfly/patternfly.min.css`,
|
`${url.resourcesCommonPath}/node_modules/@patternfly/patternfly/patternfly.min.css`,
|
||||||
@ -38,41 +38,32 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
|
|||||||
`${url.resourcesCommonPath}/lib/pficon/pficon.css`,
|
`${url.resourcesCommonPath}/lib/pficon/pficon.css`,
|
||||||
`${url.resourcesPath}/css/login.css`
|
`${url.resourcesPath}/css/login.css`
|
||||||
],
|
],
|
||||||
"scripts": [
|
"scriptTags": [
|
||||||
{
|
{
|
||||||
"isModule": true,
|
"type": "module",
|
||||||
"source": {
|
"src": `${url.resourcesPath}/js/menu-button-links.js`
|
||||||
"type": "url",
|
|
||||||
"src": `${url.resourcesPath}/js/menu-button-links.js`
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
...(authenticationSession === undefined
|
...(authenticationSession === undefined
|
||||||
? []
|
? []
|
||||||
: [
|
: [
|
||||||
{
|
{
|
||||||
"isModule": true,
|
"type": "module",
|
||||||
"source": {
|
"textContent": [
|
||||||
"type": "inline" as const,
|
`import { checkCookiesAndSetTimer } from "${url.resourcesPath}/js/authChecker.js";`,
|
||||||
"code": [
|
``,
|
||||||
`import { checkCookiesAndSetTimer } from "${url.resourcesPath}/js/authChecker.js";`,
|
`checkCookiesAndSetTimer(`,
|
||||||
``,
|
` "${authenticationSession.authSessionId}",`,
|
||||||
`checkCookiesAndSetTimer(`,
|
` "${authenticationSession.tabId}",`,
|
||||||
` "${authenticationSession.authSessionId}",`,
|
` "${url.ssoLoginInOtherTabsUrl}"`,
|
||||||
` "${authenticationSession.tabId}",`,
|
`);`
|
||||||
` "${url.ssoLoginInOtherTabsUrl}"`,
|
].join("\n")
|
||||||
`);`
|
} as const
|
||||||
].join("\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]),
|
]),
|
||||||
...scripts.map(
|
...scripts.map(
|
||||||
script =>
|
script =>
|
||||||
({
|
({
|
||||||
"isModule": false,
|
"type": "text/javascript",
|
||||||
"source": {
|
"src": script
|
||||||
"type": "url",
|
|
||||||
"src": script
|
|
||||||
}
|
|
||||||
} as const)
|
} as const)
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
@ -82,7 +73,7 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
|
|||||||
"documentTitle": msgStr("loginTitle", kcContext.realm.displayName)
|
"documentTitle": msgStr("loginTitle", kcContext.realm.displayName)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!isReady) {
|
if (!areAllStyleSheetsLoaded) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import "keycloakify/tools/Array.prototype.every";
|
import "keycloakify/tools/Array.prototype.every";
|
||||||
import { useMemo, useReducer, Fragment, type Dispatch } from "react";
|
import { useMemo, useReducer, useEffect, Fragment, type Dispatch } from "react";
|
||||||
import { id } from "tsafe/id";
|
import { id } from "tsafe/id";
|
||||||
import type { MessageKey } from "keycloakify/login/i18n/i18n";
|
import type { MessageKey } from "keycloakify/login/i18n/i18n";
|
||||||
import type { Attribute, Validators } from "keycloakify/login/kcContext/KcContext";
|
import type { Attribute, Validators } from "keycloakify/login/kcContext/KcContext";
|
||||||
@ -7,9 +7,9 @@ import { useConstCallback } from "keycloakify/tools/useConstCallback";
|
|||||||
import { emailRegexp } from "keycloakify/tools/emailRegExp";
|
import { emailRegexp } from "keycloakify/tools/emailRegExp";
|
||||||
import type { KcContext, PasswordPolicies } from "keycloakify/login/kcContext/KcContext";
|
import type { KcContext, PasswordPolicies } from "keycloakify/login/kcContext/KcContext";
|
||||||
import { assert, type Equals } from "tsafe/assert";
|
import { assert, type Equals } from "tsafe/assert";
|
||||||
import type { I18n } from "../i18n";
|
|
||||||
import { formatNumber } from "keycloakify/tools/formatNumber";
|
import { formatNumber } from "keycloakify/tools/formatNumber";
|
||||||
import { usePrepareTemplate } from "keycloakify/lib/usePrepareTemplate";
|
import { createUseInsertScriptTags } from "keycloakify/tools/useInsertScriptTags";
|
||||||
|
import type { I18n } from "../i18n";
|
||||||
|
|
||||||
export type FormFieldError = {
|
export type FormFieldError = {
|
||||||
errorMessage: JSX.Element;
|
errorMessage: JSX.Element;
|
||||||
@ -102,26 +102,24 @@ namespace internal {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { useInsertScriptTags } = createUseInsertScriptTags();
|
||||||
|
|
||||||
export function useUserProfileForm(params: ParamsOfUseUserProfileForm): ReturnTypeOfUseUserProfileForm {
|
export function useUserProfileForm(params: ParamsOfUseUserProfileForm): ReturnTypeOfUseUserProfileForm {
|
||||||
const { kcContext, i18n, doMakeUserConfirmPassword } = params;
|
const { kcContext, i18n, doMakeUserConfirmPassword } = params;
|
||||||
|
|
||||||
usePrepareTemplate({
|
const { insertScriptTags } = useInsertScriptTags({
|
||||||
"styles": [],
|
"scriptTags": Object.keys(kcContext.profile?.html5DataAnnotations ?? {})
|
||||||
"scripts": Object.keys(kcContext.profile?.html5DataAnnotations ?? {})
|
|
||||||
.filter(key => key !== "kcMultivalued" && key !== "kcNumberFormat") // NOTE: Keycloakify handles it.
|
.filter(key => key !== "kcMultivalued" && key !== "kcNumberFormat") // NOTE: Keycloakify handles it.
|
||||||
.map(key => ({
|
.map(key => ({
|
||||||
"isModule": true,
|
"type": "module",
|
||||||
"source": {
|
"src": `${kcContext.url.resourcesPath}/js/${key}.js`
|
||||||
"type": "url",
|
}))
|
||||||
"src": `${kcContext.url.resourcesPath}/js/${key}.js`
|
|
||||||
}
|
|
||||||
})),
|
|
||||||
"htmlClassName": undefined,
|
|
||||||
"bodyClassName": undefined,
|
|
||||||
"htmlLangProperty": undefined,
|
|
||||||
"documentTitle": undefined
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
insertScriptTags();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const { getErrors } = useGetErrors({
|
const { getErrors } = useGetErrors({
|
||||||
kcContext,
|
kcContext,
|
||||||
i18n
|
i18n
|
||||||
|
82
src/tools/useInsertLinkTags.ts
Normal file
82
src/tools/useInsertLinkTags.ts
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import { useReducer, useEffect } from "react";
|
||||||
|
|
||||||
|
export function createUseInsertLinkTags() {
|
||||||
|
let linkTagsContext:
|
||||||
|
| {
|
||||||
|
styleSheetHrefs: string[];
|
||||||
|
prAreAllStyleSheetsLoaded: Promise<void>;
|
||||||
|
remove: () => void;
|
||||||
|
}
|
||||||
|
| undefined = undefined;
|
||||||
|
|
||||||
|
/** NOTE: The hrefs can't changes. There should be only one one call on this. */
|
||||||
|
function useInsertLinkTags(params: { hrefs: string[] }) {
|
||||||
|
const { hrefs } = params;
|
||||||
|
|
||||||
|
const [areAllStyleSheetsLoaded, setAllStyleSheetLoaded] = useReducer(() => true, hrefs.length === 0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isActive = true;
|
||||||
|
|
||||||
|
mount_link_tags: {
|
||||||
|
if (linkTagsContext !== undefined) {
|
||||||
|
if (JSON.stringify(linkTagsContext.styleSheetHrefs) === JSON.stringify(hrefs)) {
|
||||||
|
break mount_link_tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
linkTagsContext.remove();
|
||||||
|
|
||||||
|
linkTagsContext = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastMountedHtmlElement: HTMLLinkElement | undefined = undefined;
|
||||||
|
|
||||||
|
const prs: Promise<void>[] = [];
|
||||||
|
const removeFns: (() => void)[] = [];
|
||||||
|
|
||||||
|
for (const href of hrefs) {
|
||||||
|
const htmlElement = document.createElement("link");
|
||||||
|
|
||||||
|
prs.push(new Promise<void>(resolve => htmlElement.addEventListener("load", () => resolve())));
|
||||||
|
|
||||||
|
htmlElement.rel = "stylesheet";
|
||||||
|
|
||||||
|
htmlElement.href = href;
|
||||||
|
|
||||||
|
if (lastMountedHtmlElement !== undefined) {
|
||||||
|
lastMountedHtmlElement.insertAdjacentElement("afterend", htmlElement);
|
||||||
|
} else {
|
||||||
|
document.head.prepend(htmlElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeFns.push(() => {
|
||||||
|
htmlElement.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
lastMountedHtmlElement = htmlElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
linkTagsContext = {
|
||||||
|
"styleSheetHrefs": hrefs,
|
||||||
|
"prAreAllStyleSheetsLoaded": Promise.all(prs).then(() => undefined),
|
||||||
|
"remove": () => removeFns.forEach(fn => fn())
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
linkTagsContext.prAreAllStyleSheetsLoaded.then(() => {
|
||||||
|
if (!isActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAllStyleSheetLoaded();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isActive = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { areAllStyleSheetsLoaded };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { useInsertLinkTags };
|
||||||
|
}
|
@ -1,4 +1,6 @@
|
|||||||
import { useEffect } from "react";
|
import { useCallback } from "react";
|
||||||
|
import { useConst } from "keycloakify/tools/useConst";
|
||||||
|
import { assert } from "tsafe/assert";
|
||||||
|
|
||||||
export type ScriptTag = ScriptTag.TextContent | ScriptTag.Src;
|
export type ScriptTag = ScriptTag.TextContent | ScriptTag.Src;
|
||||||
|
|
||||||
@ -8,56 +10,76 @@ export namespace ScriptTag {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type TextContent = Common & {
|
export type TextContent = Common & {
|
||||||
isModule: boolean;
|
|
||||||
sourceType: "textContent";
|
|
||||||
id: string;
|
|
||||||
textContent: string;
|
textContent: string;
|
||||||
};
|
};
|
||||||
export type Src = Common & {
|
export type Src = Common & {
|
||||||
isModule: boolean;
|
|
||||||
sourceType: "src";
|
|
||||||
src: string;
|
src: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE: Loaded scripts cannot be unloaded so we need to keep track of them
|
export function createUseInsertScriptTags() {
|
||||||
// to avoid loading them multiple times.
|
let areScriptsInserted = false;
|
||||||
const loadedScripts = new Set<string>();
|
|
||||||
|
|
||||||
export function useInsertScriptTags(params: { scriptTags: ScriptTag[] }) {
|
function useInsertScriptTags(params: { scriptTags: ScriptTag[] }) {
|
||||||
const { scriptTags } = params;
|
const { scriptTags } = params;
|
||||||
|
|
||||||
useEffect(() => {
|
const currentScriptTagsRef = useConst(() => ({ "current": scriptTags }));
|
||||||
for (const scriptTag of scriptTags) {
|
|
||||||
const scriptId = (() => {
|
currentScriptTagsRef.current = scriptTags;
|
||||||
switch (scriptTag.sourceType) {
|
|
||||||
case "src":
|
const insertScriptTags = useCallback(() => {
|
||||||
return scriptTag.src;
|
{
|
||||||
case "textContent":
|
const getFingerprint = (scriptTags: ScriptTag[]) =>
|
||||||
return scriptTag.textContent;
|
scriptTags
|
||||||
|
.map((scriptTag): string => {
|
||||||
|
if ("textContent" in scriptTag) {
|
||||||
|
return scriptTag.textContent;
|
||||||
|
}
|
||||||
|
if ("src" in scriptTag) {
|
||||||
|
return scriptTag.src;
|
||||||
|
}
|
||||||
|
assert(false);
|
||||||
|
})
|
||||||
|
.join("---");
|
||||||
|
|
||||||
|
if (getFingerprint(scriptTags) !== getFingerprint(currentScriptTagsRef.current)) {
|
||||||
|
// NOTE: We can't unload script, in storybook if we switch from one page to another
|
||||||
|
// and the scripts have changed we must reload.
|
||||||
|
window.location.reload();
|
||||||
|
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
})();
|
|
||||||
|
|
||||||
if (loadedScripts.has(scriptId)) {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const htmlElement = document.createElement("script");
|
if (areScriptsInserted) {
|
||||||
|
return;
|
||||||
htmlElement.type = scriptTag.type;
|
|
||||||
|
|
||||||
switch (scriptTag.sourceType) {
|
|
||||||
case "src":
|
|
||||||
htmlElement.src = scriptTag.src;
|
|
||||||
break;
|
|
||||||
case "textContent":
|
|
||||||
htmlElement.textContent = scriptTag.textContent;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.head.appendChild(htmlElement);
|
scriptTags.forEach(scriptTag => {
|
||||||
|
const htmlElement = document.createElement("script");
|
||||||
|
|
||||||
loadedScripts.add(scriptId);
|
htmlElement.type = scriptTag.type;
|
||||||
}
|
|
||||||
});
|
(() => {
|
||||||
|
if ("textContent" in scriptTag) {
|
||||||
|
htmlElement.textContent = scriptTag.textContent;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ("src" in scriptTag) {
|
||||||
|
htmlElement.src = scriptTag.src;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assert(false);
|
||||||
|
})();
|
||||||
|
|
||||||
|
document.head.appendChild(htmlElement);
|
||||||
|
});
|
||||||
|
|
||||||
|
areScriptsInserted = true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { insertScriptTags };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { useInsertScriptTags };
|
||||||
}
|
}
|
||||||
|
21
src/tools/useSetClassName.ts
Normal file
21
src/tools/useSetClassName.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
export function useSetClassName(params: { qualifiedName: "html" | "body"; className: string | undefined }) {
|
||||||
|
const { qualifiedName, className } = params;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (className === undefined || className === "") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const htmlClassList = document.getElementsByTagName(qualifiedName)[0].classList;
|
||||||
|
|
||||||
|
const tokens = className.split(" ");
|
||||||
|
|
||||||
|
htmlClassList.add(...tokens);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
htmlClassList.remove(...tokens);
|
||||||
|
};
|
||||||
|
}, [className]);
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user