Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
@@ -0,0 +1,57 @@
|
||||
import { Link } from "react-router";
|
||||
import type { KbArticleDto } from "../dtos/KbArticleDto";
|
||||
import ColorTextUtils from "~/utils/shared/colors/ColorTextUtils";
|
||||
import type { Colors } from "~/application/enums/shared/Colors";
|
||||
import clsx from "clsx";
|
||||
import type { KnowledgeBaseDto } from "../dtos/KnowledgeBaseDto";
|
||||
import EmptyState from "~/components/ui/emptyState/EmptyState";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronRightIcon, StarIcon } from "~/components/ui/icons/vendor";
|
||||
|
||||
export default function KbFeaturedArticles({ kb, items }: { kb: KnowledgeBaseDto; items: KbArticleDto[] }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<FeaturedArticlesIcon color={kb.color} />
|
||||
<h2 className="text-2xl font-bold">{t("knowledgeBase.featuredArticles")}</h2>
|
||||
</div>
|
||||
{items.length === 0 && <EmptyState className="bg-background" captions={{ thereAreNo: t("knowledgeBase.article.emptyFeatured") }} />}
|
||||
<div className={clsx("grid gap-2", items.length === 1 ? "grid-cols-1" : "sm:grid-cols-2")}>
|
||||
{items.map((item) => {
|
||||
return (
|
||||
<Link
|
||||
to={item.href}
|
||||
key={item.id}
|
||||
className="rounded-md border border-border bg-background px-3 py-4 shadow-2xs hover:bg-secondary hover:border-secondary-foreground"
|
||||
>
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<div className="grow font-medium">{item.title}</div>
|
||||
<div className="shrink-0">
|
||||
{/* <svg className="h-5 w-5" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path d="M5 12h14M12 5l7 7-7 7"></path>
|
||||
</svg> */}
|
||||
<ChevronRightIcon className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FeaturedArticlesIcon({ color }: { color: Colors }) {
|
||||
return (
|
||||
<StarIcon className={clsx("h-5 w-5", ColorTextUtils.getText500(color))} />
|
||||
);
|
||||
}
|
||||
|
||||
// function CategoriesIcon() {
|
||||
// return (
|
||||
// <svg className="h-7 w-7" fill="currentColor" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" viewBox="0 0 48 48">
|
||||
// <path d="M 10.5 5 C 8.019 5 6 7.019 6 9.5 L 6 31.5 C 6 33.981 8.019 36 10.5 36 L 16 36 L 16 33 L 10.5 33 C 9.673 33 9 32.327 9 31.5 L 9 31 L 16 31 L 16 16.5 C 16 16.331 16.013391 16.166 16.025391 16 L 13.5 16 C 12.671 16 12 15.329 12 14.5 C 12 13.671 12.671 13 13.5 13 L 17.03125 13 C 18.18825 11.199 20.205 10 22.5 10 L 30 10 L 30 9.5 C 30 7.019 27.981 5 25.5 5 L 10.5 5 z M 22.5 12 C 20.019 12 18 14.019 18 16.5 L 18 38.5 C 18 40.981 20.019 43 22.5 43 L 40.5 43 C 41.329 43 42 42.329 42 41.5 C 42 40.671 41.329 40 40.5 40 L 22.5 40 C 21.673 40 21 39.327 21 38.5 L 21 38 L 40.5 38 C 41.329 38 42 37.329 42 36.5 L 42 16.5 C 42 14.019 39.981 12 37.5 12 L 22.5 12 z M 25.5 20 L 34.5 20 C 35.329 20 36 20.671 36 21.5 C 36 22.329 35.329 23 34.5 23 L 25.5 23 C 24.671 23 24 22.329 24 21.5 C 24 20.671 24.671 20 25.5 20 z"></path>
|
||||
// </svg>
|
||||
// );
|
||||
// }
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Link, useNavigate, useParams, useSearchParams } from "react-router";
|
||||
import { Fragment } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { KnowledgeBaseDto } from "../dtos/KnowledgeBaseDto";
|
||||
import clsx from "clsx";
|
||||
import ColorBackgroundUtils from "~/utils/shared/colors/ColorBackgroundUtils";
|
||||
import ColorTextUtils from "~/utils/shared/colors/ColorTextUtils";
|
||||
import ColorFocusUtils from "~/utils/shared/colors/ColorFocusUtils";
|
||||
import ColorRingUtils from "~/utils/shared/colors/ColorRingUtils";
|
||||
import ColorGradientUtils from "~/utils/shared/colors/ColorGradientUtils";
|
||||
import { logoLight, logoDark } from "~/assets/brand/default";
|
||||
import KnowledgeBaseUtils from "../utils/KnowledgeBaseUtils";
|
||||
import KbSearch from "./KbSearch";
|
||||
|
||||
interface Props {
|
||||
kb: KnowledgeBaseDto;
|
||||
// currentVersion: string | undefined;
|
||||
withTitleAndDescription: boolean;
|
||||
withSearch?: boolean;
|
||||
}
|
||||
export default function KbHeader({ kb, withTitleAndDescription, withSearch = true }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const params = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const logoAlt = t("knowledgeBase.header.logoAlt", { title: kb.title });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={clsx("bg-linear-to-r text-white", ColorGradientUtils.getFrom800To900(kb.color))}>
|
||||
<div className="mx-auto max-w-5xl space-y-8 px-8 py-6">
|
||||
<div className="flex h-12 items-center justify-between space-x-2">
|
||||
<Link to={KnowledgeBaseUtils.getKbUrl({ kb, params })} className="flex select-none items-center space-x-2">
|
||||
{kb.logo === "light" ? (
|
||||
<img className="h-8 w-auto" src={logoLight} alt={logoAlt} />
|
||||
) : kb.logo === "dark" ? (
|
||||
<img className="h-8 w-auto" src={logoDark} alt={logoAlt} />
|
||||
) : (
|
||||
<img className="h-8 w-auto" src={kb.logo} alt={logoAlt} />
|
||||
)}
|
||||
{!withTitleAndDescription && (
|
||||
<Fragment>
|
||||
<span className={clsx("hidden px-2 sm:block", ColorTextUtils.getText300(kb.color))}>|</span>
|
||||
<span className="hidden font-semibold sm:block">{kb.title}</span>
|
||||
</Fragment>
|
||||
)}
|
||||
</Link>
|
||||
<div className="flex items-center space-x-4">
|
||||
{kb.links.map((link) => {
|
||||
return (
|
||||
<a key={link.href} href={link.href} className={clsx("transition-colors duration-150 hover:text-white", ColorTextUtils.getText300(kb.color))}>
|
||||
{link.name}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
<div className="flex items-center space-x-2">
|
||||
{kb.languages.length > 1 && (
|
||||
<select
|
||||
id="lang"
|
||||
name="lang"
|
||||
defaultValue={params.lang || kb.defaultLanguage}
|
||||
className={clsx(
|
||||
"block w-full rounded-md border-0 py-1.5 pl-3 pr-10 text-white ring-1 ring-inset focus:ring-2 sm:text-sm sm:leading-6",
|
||||
ColorBackgroundUtils.getBg700(kb.color),
|
||||
ColorRingUtils.getRing800(kb.color),
|
||||
ColorFocusUtils.getRing600(kb.color)
|
||||
)}
|
||||
onChange={(e) => {
|
||||
navigate(
|
||||
`${KnowledgeBaseUtils.getKbUrl({
|
||||
kb,
|
||||
params: {
|
||||
lang: e.target.value,
|
||||
},
|
||||
})}?${searchParams.toString()}`
|
||||
);
|
||||
}}
|
||||
>
|
||||
{kb.languages.map((lang) => {
|
||||
const language = KnowledgeBaseUtils.supportedLanguages.find((f) => f.value === lang);
|
||||
if (!language) return null;
|
||||
return (
|
||||
<Fragment key={lang}>
|
||||
<option key={lang} value={lang}>
|
||||
{KnowledgeBaseUtils.getLanguageName(language.value, t)}
|
||||
</option>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{withTitleAndDescription && (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-semibold tracking-tight sm:text-4xl">{kb.title}</h1>
|
||||
<p className={clsx("text-base", ColorTextUtils.getText300(kb.color))}>{kb.description}</p>
|
||||
</div>
|
||||
)}
|
||||
{withSearch && <KbSearch kb={kb} autoFocus={withTitleAndDescription} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import clsx from "clsx";
|
||||
import { InfoIcon, SearchIcon, XIcon } from "~/components/ui/icons/vendor";
|
||||
import ColorBackgroundUtils from "~/utils/shared/colors/ColorBackgroundUtils";
|
||||
import ColorFocusUtils from "~/utils/shared/colors/ColorFocusUtils";
|
||||
import ColorFocusWithinUtils from "~/utils/shared/colors/ColorFocusWithinUtils";
|
||||
import ColorPlaceholderUtils from "~/utils/shared/colors/ColorPlaceholderUtils";
|
||||
import ColorRingUtils from "~/utils/shared/colors/ColorRingUtils";
|
||||
import ColorTextUtils from "~/utils/shared/colors/ColorTextUtils";
|
||||
import { KnowledgeBaseDto } from "../dtos/KnowledgeBaseDto";
|
||||
import { Link, useLoaderData, useSearchParams } from "react-router";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useDebounce } from "use-debounce";
|
||||
import { KbSearchResultDto } from "../dtos/KbSearchResultDto";
|
||||
import ColorBorderUtils from "~/utils/shared/colors/ColorBorderUtils";
|
||||
import { Colors } from "~/application/enums/shared/Colors";
|
||||
import { KbArticleDto } from "../dtos/KbArticleDto";
|
||||
import ColorHoverUtils from "~/utils/shared/colors/ColorHoverUtils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function KbSearch({ kb, autoFocus }: { kb: KnowledgeBaseDto; autoFocus: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
let data = useLoaderData<
|
||||
| {
|
||||
search: KbSearchResultDto | undefined;
|
||||
}
|
||||
| undefined
|
||||
>();
|
||||
let [search, setSearch] = useState(data?.search?.query);
|
||||
let [debouncedQuery] = useDebounce(search, 500);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedQuery) {
|
||||
searchParams.set("q", debouncedQuery);
|
||||
} else {
|
||||
searchParams.delete("q");
|
||||
}
|
||||
if (searchParams.toString()) {
|
||||
setSearchParams(searchParams);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [debouncedQuery]);
|
||||
return (
|
||||
<div className="w-full space-y-2">
|
||||
<div className={clsx("relative", ColorTextUtils.getText400(kb.color), ColorFocusWithinUtils.getText600(kb.color))}>
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-4">
|
||||
<SearchIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<input
|
||||
autoFocus={autoFocus}
|
||||
autoComplete="off"
|
||||
className={clsx(
|
||||
" focus:bg-background block w-full rounded-md border-0 px-6 py-5 pl-12 outline-hidden ring-1 ring-inset transition-all focus:ring-2 focus:ring-inset",
|
||||
ColorBackgroundUtils.getBg800(kb.color),
|
||||
ColorTextUtils.getText300(kb.color),
|
||||
ColorPlaceholderUtils.getText400(kb.color),
|
||||
ColorFocusUtils.getText600(kb.color),
|
||||
ColorFocusUtils.getPlaceholder600(kb.color),
|
||||
ColorFocusUtils.getRing600(kb.color),
|
||||
ColorRingUtils.getRing800(kb.color)
|
||||
)}
|
||||
type="text"
|
||||
aria-label={t("knowledgeBase.search.label")}
|
||||
placeholder={t("knowledgeBase.search.placeholder")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
{search && (
|
||||
<button type="button" className="absolute inset-y-0 right-0 flex items-center pr-4" onClick={() => setSearch("")} aria-label={t("knowledgeBase.search.clear")}>
|
||||
<XIcon className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{search && data?.search !== undefined && (
|
||||
<div>
|
||||
{!data.search || data.search.articles?.length === 0 ? (
|
||||
<div className={clsx("rounded-md border", ColorBorderUtils.get300(kb.color), ColorBackgroundUtils.getBg50(kb.color))}>
|
||||
<div className="rounded-md p-4">
|
||||
<div className="flex">
|
||||
<div className="shrink-0">
|
||||
<InfoIcon className={clsx("h-5 w-5", ColorTextUtils.getText400(kb.color))} />
|
||||
</div>
|
||||
|
||||
<div className="ml-3 w-full">
|
||||
<h3 className={clsx("text-sm font-medium leading-5", ColorTextUtils.getText800(kb.color))}>
|
||||
{t("knowledgeBase.search.noResults", { query: data.search.query })}
|
||||
</h3>
|
||||
<div className={clsx("mt-2 text-sm leading-5", ColorTextUtils.getText800(kb.color))}>
|
||||
<div>
|
||||
<p className="text-sm">{t("knowledgeBase.search.tryDifferent")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
{data.search.articles.map((item) => (
|
||||
<Card key={item.id} kb={kb} item={item} highlightText={debouncedQuery ?? ""} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Highlight({ className, color, children, highlightText }: { className: string; color: Colors; children: string; highlightText: string }) {
|
||||
const parts = children.split(new RegExp(`(${highlightText})`, "gi"));
|
||||
return (
|
||||
<span className={clsx(className, " line-clamp-3")}>
|
||||
{parts.map((part, i) =>
|
||||
part.toLowerCase() === highlightText.toLowerCase() ? (
|
||||
<span key={i} className={clsx(ColorBackgroundUtils.getBg400(color), ColorTextUtils.getText100(color))}>
|
||||
{part}
|
||||
</span>
|
||||
) : (
|
||||
part
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({ kb, item, highlightText }: { kb: KnowledgeBaseDto; item: KbArticleDto; highlightText: string }) {
|
||||
const contentLines = item.contentPublishedAsText.split("\n");
|
||||
const highlightedLines = contentLines
|
||||
.map((line, i) => ({ line, isHighlighted: line.toLowerCase().includes(highlightText.toLowerCase()), i }))
|
||||
.filter(({ isHighlighted }) => isHighlighted)
|
||||
.slice(0, 3);
|
||||
|
||||
return (
|
||||
<div key={item.title} className={clsx("group rounded-md p-2 shadow-md", ColorBackgroundUtils.getBg800(kb.color), ColorHoverUtils.getBg900(kb.color))}>
|
||||
<Link to={item.href} className="w-full">
|
||||
<div className={clsx("space-y-1")}>
|
||||
<h2>
|
||||
<Highlight className={clsx(ColorTextUtils.getText100(kb.color))} color={kb.color} highlightText={highlightText}>
|
||||
{item.title}
|
||||
</Highlight>
|
||||
</h2>
|
||||
<p>
|
||||
<Highlight className={clsx(ColorTextUtils.getText300(kb.color))} color={kb.color} highlightText={highlightText}>
|
||||
{item.description}
|
||||
</Highlight>
|
||||
</p>
|
||||
{highlightedLines.map(({ line, i }) => (
|
||||
<p key={i}>
|
||||
<Highlight className={clsx(ColorTextUtils.getText300(kb.color))} color={kb.color} highlightText={highlightText}>
|
||||
{line}
|
||||
</Highlight>
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export default function KbSkeletons({ n = 5 }: { n?: number }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: n }).map((_, i) => {
|
||||
return (
|
||||
<div key={i} className="border-border hover:border-theme-500 bg-background group rounded-md border">
|
||||
<div className="w-full">
|
||||
<div className="flex items-center space-x-8 p-6">
|
||||
<div className="shrink-0">
|
||||
<div className="h-12 w-12 animate-pulse rounded-md bg-gray-300"></div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="h-4 w-32 animate-pulse rounded-md bg-gray-300 font-bold"></div>
|
||||
<div className="mt-2 h-3 w-48 animate-pulse rounded-md bg-gray-300 text-sm"></div>
|
||||
<div className="mt-6 flex items-center space-x-2">
|
||||
<div className="h-4 w-16 animate-pulse rounded-md bg-gray-300 text-sm"></div>
|
||||
<div className="text-sm text-gray-300">|</div>
|
||||
<div className="h-4 w-48 animate-pulse rounded-md bg-gray-300 text-sm"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { KnowledgeBaseDto } from "../../dtos/KnowledgeBaseDto";
|
||||
import BreadcrumbSimple from "~/components/ui/breadcrumbs/BreadcrumbSimple";
|
||||
import type { KbArticleDto } from "~/modules/knowledgeBase/dtos/KbArticleDto";
|
||||
import type { KbCategoryDto } from "~/modules/knowledgeBase/dtos/KbCategoryDto";
|
||||
import KbArticleContent from "./KbArticleContent";
|
||||
import { useParams } from "react-router";
|
||||
import KnowledgeBaseUtils from "../../utils/KnowledgeBaseUtils";
|
||||
|
||||
export default function KbArticle({
|
||||
kb,
|
||||
category,
|
||||
item,
|
||||
userState,
|
||||
actions,
|
||||
withMenu = true,
|
||||
}: {
|
||||
kb: KnowledgeBaseDto;
|
||||
category: KbCategoryDto;
|
||||
item: KbArticleDto;
|
||||
userState: {
|
||||
hasThumbsUp: boolean;
|
||||
hasThumbsDown: boolean;
|
||||
};
|
||||
actions: {
|
||||
onThumbsUp: () => void;
|
||||
onThumbsDown: () => void;
|
||||
};
|
||||
withMenu?: boolean;
|
||||
}) {
|
||||
const params = useParams();
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{withMenu && (
|
||||
<BreadcrumbSimple
|
||||
menu={[
|
||||
// {
|
||||
// title: kb.title,
|
||||
// routePath: KnowledgeBaseUtils.getKbUrl({ kb, params }),
|
||||
// },
|
||||
{
|
||||
title: category.title,
|
||||
routePath: KnowledgeBaseUtils.getCategoryUrl({ kb, category, params }),
|
||||
},
|
||||
{
|
||||
title: item.title,
|
||||
routePath: item.href,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
<KbArticleContent item={item} content={item.contentPublished} userState={userState} actions={actions} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
import { Link } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ThumbsDownIcon from "~/components/ui/icons/ThumbsDownIcon";
|
||||
import ThumbsDownIconFilled from "~/components/ui/icons/ThumbsDownIconFilled";
|
||||
import ThumbsUpIcon from "~/components/ui/icons/ThumbsUpIcon";
|
||||
import ThumbsUpIconFilled from "~/components/ui/icons/ThumbsUpIconFilled";
|
||||
import { KbArticleDto } from "~/modules/knowledgeBase/dtos/KbArticleDto";
|
||||
import { renderSanitizedMarkdown } from "~/utils/html-sanitizer";
|
||||
import { useLocaleFormatters } from "~/locale/format";
|
||||
|
||||
export default function KbArticleContent({
|
||||
item,
|
||||
content,
|
||||
actions,
|
||||
userState,
|
||||
}: {
|
||||
item: KbArticleDto;
|
||||
content: string;
|
||||
userState?: {
|
||||
hasThumbsUp: boolean;
|
||||
hasThumbsDown: boolean;
|
||||
};
|
||||
actions?: {
|
||||
onThumbsUp: () => void;
|
||||
onThumbsDown: () => void;
|
||||
};
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const formatters = useLocaleFormatters();
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col space-y-6">
|
||||
<div className="flex items-center space-x-8">
|
||||
<div className="flex w-full flex-col">
|
||||
<div className="flex items-center space-x-3">
|
||||
<h1 className="text-xl font-bold md:text-3xl">{item.title}</h1>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-2 font-light">{item.description}</p>
|
||||
<div className="flex justify-between space-x-2">
|
||||
<li className="mt-5 flex items-center space-x-2">
|
||||
{item.createdByUser?.avatar && (
|
||||
<img
|
||||
src={item.createdByUser.avatar}
|
||||
alt={`${item.createdByUser.firstName} ${item.createdByUser.lastName}`}
|
||||
className="h-8 w-8 rounded-full"
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
{item.createdByUser && (
|
||||
<dl className="whitespace-no-wrap text-sm font-medium leading-5">
|
||||
<dt className="sr-only">{t("knowledgeBase.article.author")}</dt>
|
||||
<dd className="">
|
||||
<span className="text-muted-foreground font-normal">{t("shared.by")}</span> {item.createdByUser.firstName} {item.createdByUser.lastName}
|
||||
</dd>
|
||||
</dl>
|
||||
)}
|
||||
<dl className="whitespace-no-wrap text-xs leading-5">{formatters.relativeDate(item.createdAt)}</dl>
|
||||
</div>
|
||||
</li>
|
||||
{actions && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<button type="button" onClick={actions.onThumbsUp} className="text-muted-foreground hover:text-foreground flex items-center space-x-2">
|
||||
{userState?.hasThumbsUp ? <ThumbsUpIconFilled className="h-4 w-4" /> : <ThumbsUpIcon className="h-4 w-4" />}
|
||||
</button>
|
||||
<button type="button" onClick={actions.onThumbsDown} className="text-muted-foreground hover:text-foreground flex items-center space-x-2">
|
||||
{userState?.hasThumbsDown ? <ThumbsDownIconFilled className="h-4 w-4" /> : <ThumbsDownIcon className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-border border-b pt-6"></div>
|
||||
|
||||
<div className="grid gap-8 sm:grid-cols-12">
|
||||
<div className="overflow-auto sm:col-span-9">
|
||||
<div className="prose dark:prose-dark">
|
||||
<div dangerouslySetInnerHTML={{ __html: renderSanitizedMarkdown(content) }} />
|
||||
</div>
|
||||
</div>
|
||||
{item.relatedArticles.length > 0 && (
|
||||
<div className="space-y-3 py-5 sm:col-span-3">
|
||||
<div className="text-muted-foreground text-sm font-medium">{t("knowledgeBase.article.relatedArticles")}</div>
|
||||
<div className="space-y-2">
|
||||
{item.relatedArticles.map((x) => (
|
||||
<Link to={x.href} key={x.href} className="flex items-center space-x-2 hover:underline">
|
||||
<div className="text-sm">{x.title}</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Link } from "react-router";
|
||||
import RightIcon from "~/components/ui/icons/RightIcon";
|
||||
import clsx from "clsx";
|
||||
import ColorHoverUtils from "~/utils/shared/colors/ColorHoverUtils";
|
||||
import type { KnowledgeBaseDto } from "../../dtos/KnowledgeBaseDto";
|
||||
|
||||
export default function KbArticles({
|
||||
kb,
|
||||
items,
|
||||
}: {
|
||||
kb: KnowledgeBaseDto;
|
||||
items: {
|
||||
order: number;
|
||||
title: string;
|
||||
description: string;
|
||||
href: string;
|
||||
sectionId: string | null;
|
||||
}[];
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-background py-3">
|
||||
{items.map((item) => {
|
||||
return (
|
||||
<div key={item.title} className={clsx("group", ColorHoverUtils.getBorder500(kb.color))}>
|
||||
<Link to={item.href}>
|
||||
<div className="flex items-center justify-between space-x-2 px-6 py-3 hover:bg-secondary">
|
||||
<div className="">
|
||||
<div className={clsx(" text-muted-foreground group-hover:text-foreground")}>{item.title}</div>
|
||||
</div>
|
||||
<RightIcon className={clsx("h-5 w-5 shrink-0 text-muted-foreground group-hover:text-foreground")} />
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import EmptyState from "~/components/ui/emptyState/EmptyState";
|
||||
import type { KbCategoryDto } from "../../dtos/KbCategoryDto";
|
||||
import type { KnowledgeBaseDto } from "../../dtos/KnowledgeBaseDto";
|
||||
import KbArticles from "./KbArticles";
|
||||
import KnowledgeBaseUtils from "../../utils/KnowledgeBaseUtils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function KbArticlesBySection({ kb, item }: { kb: KnowledgeBaseDto; item: KbCategoryDto }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div>
|
||||
{item.articles.length === 0 ? (
|
||||
<EmptyState className="bg-background" captions={{ thereAreNo: t("knowledgeBase.article.empty") }} />
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{KnowledgeBaseUtils.getCategoryArticlesBySections({ kb, category: item }).map((item, idx) => {
|
||||
return (
|
||||
<div key={idx} className="space-y-3">
|
||||
{item.section && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="text-xl font-bold">{item.section.title}</div>
|
||||
</div>
|
||||
)}
|
||||
<KbArticles kb={kb} items={item.articles} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import OrderListButtons from "~/components/ui/sort/OrderListButtons";
|
||||
import TableSimple from "~/components/ui/tables/TableSimple";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function KbSortArticles({
|
||||
items,
|
||||
}: {
|
||||
items: {
|
||||
id: string;
|
||||
order: number;
|
||||
title: string;
|
||||
}[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label className="text-muted-foreground block text-xs font-medium">{t("knowledgeBase.authoring.articles.title")}</label>
|
||||
<TableSimple
|
||||
items={items}
|
||||
headers={[
|
||||
{
|
||||
name: "order",
|
||||
title: "",
|
||||
value: (i, idx) => <OrderListButtons index={idx} items={items} editable={true} />,
|
||||
},
|
||||
{
|
||||
name: "title",
|
||||
title: t("shared.title"),
|
||||
value: (item) => item.title,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import clsx from "clsx";
|
||||
import { lazy, Suspense, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import InputText from "~/components/ui/input/InputText";
|
||||
import { KnowledgeBaseArticleWithDetails } from "~/modules/knowledgeBase/db/kbArticles.db.server";
|
||||
import useLocalStorage from "~/utils/hooks/use-local-storage";
|
||||
|
||||
const NovelEditor = lazy(() => import("~/modules/novel/ui/editor"));
|
||||
|
||||
export default function KbArticleForm({ item }: { item: KnowledgeBaseArticleWithDetails }) {
|
||||
const { t } = useTranslation();
|
||||
const [content, setContent] = useLocalStorage(item.id, item.contentDraft);
|
||||
const [contentType, setContentType] = useState(item?.contentType ?? "wysiwyg");
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="grid gap-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between space-x-2">
|
||||
{/* <label className="text-sm font-medium text-muted-foreground">Article</label> */}
|
||||
<div className="flex items-center space-x-1">
|
||||
<button type="button" onClick={() => setContentType("wysiwyg")} className="text-muted-foreground text-xs hover:underline">
|
||||
<div className={clsx(contentType === "wysiwyg" ? "font-bold" : "")}>{t("knowledgeBase.authoring.editor.wysiwyg")}</div>
|
||||
</button>
|
||||
<div>•</div>
|
||||
<button type="button" onClick={() => setContentType("markdown")} className="text-muted-foreground text-xs hover:underline">
|
||||
<div className={clsx(contentType === "markdown" ? "font-bold" : "")}>{t("knowledgeBase.authoring.editor.markdown")}</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<input name="contentType" value={contentType} readOnly hidden />
|
||||
{contentType === "wysiwyg" ? (
|
||||
<div>
|
||||
<input type="hidden" name="content" value={content} hidden readOnly />
|
||||
<Suspense fallback={null}>
|
||||
<NovelEditor
|
||||
content={content}
|
||||
onChange={(e) => {
|
||||
if (e.text) {
|
||||
setContent(e.html ?? "");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
{/* <InputText autoFocus name="content" placeholder="Content" value={content} setValue={setContent} /> */}
|
||||
</div>
|
||||
) : contentType === "markdown" ? (
|
||||
<InputText
|
||||
className="col-span-12"
|
||||
rows={6}
|
||||
editor="monaco"
|
||||
editorLanguage="markdown"
|
||||
editorTheme="vs-dark"
|
||||
editorSize="screen"
|
||||
editorFontSize={14}
|
||||
name="content"
|
||||
value={content}
|
||||
setValue={(e) => {
|
||||
if (e) {
|
||||
setContent(e.toString());
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
import { Form, useNavigation } from "react-router";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import ButtonSecondary from "~/components/ui/buttons/ButtonSecondary";
|
||||
import LoadingButton from "~/components/ui/buttons/LoadingButton";
|
||||
import InputCheckboxWithDescription from "~/components/ui/input/InputCheckboxWithDescription";
|
||||
import InputSelector from "~/components/ui/input/InputSelector";
|
||||
import InputText, { RefInputText } from "~/components/ui/input/InputText";
|
||||
import { KnowledgeBaseArticleSimple, KnowledgeBaseArticleWithDetails } from "~/modules/knowledgeBase/db/kbArticles.db.server";
|
||||
import UrlUtils from "~/utils/app/UrlUtils";
|
||||
import InputCombobox from "~/components/ui/input/InputCombobox";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import InputSelect from "~/components/ui/input/InputSelect";
|
||||
import KnowledgeBaseUtils from "../../utils/KnowledgeBaseUtils";
|
||||
import { KnowledgeBaseCategorySimple } from "../../db/kbCategories.db.server";
|
||||
|
||||
export default function KbArticleSettingsForm({
|
||||
allKnowledgeBases,
|
||||
allArticles,
|
||||
allCategories,
|
||||
item,
|
||||
onDelete,
|
||||
}: {
|
||||
allKnowledgeBases: { id: string; title: string }[];
|
||||
allArticles: KnowledgeBaseArticleSimple[];
|
||||
allCategories: KnowledgeBaseCategorySimple[];
|
||||
item: KnowledgeBaseArticleWithDetails;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const navigation = useNavigation();
|
||||
const inputTitle = useRef<RefInputText>(null);
|
||||
|
||||
const [knowledgeBaseId, setKnowledgeBaseId] = useState(item?.knowledgeBaseId ?? "");
|
||||
const [categoryId, setCategoryId] = useState<string | undefined>(item?.categoryId ?? "");
|
||||
const [category, setCategory] = useState<KnowledgeBaseCategorySimple | undefined>(undefined);
|
||||
const [sectionId, setSectionId] = useState<string | undefined>(item?.sectionId ?? "");
|
||||
const [language, setLanguage] = useState(item?.language ?? KnowledgeBaseUtils.defaultLanguage);
|
||||
const [slug, setSlug] = useState(item?.slug ?? "");
|
||||
const [title, setTitle] = useState(item?.title ?? "");
|
||||
const [description, setDescription] = useState(item?.description ?? "");
|
||||
const [seoImage, setSeoImage] = useState(item?.seoImage ?? "");
|
||||
const [isFeatured, setIsFeatured] = useState(item?.featuredOrder ? true : false);
|
||||
const [relatedArticles, setRelatedArticles] = useState(item.relatedArticles.map((f) => f.relatedArticleId) ?? []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!item) {
|
||||
const slug = UrlUtils.slugify(title);
|
||||
setSlug(slug);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [title]);
|
||||
|
||||
useEffect(() => {
|
||||
setCategory(allCategories.find((f) => f.id === categoryId));
|
||||
}, [allCategories, categoryId]);
|
||||
|
||||
return (
|
||||
<Form method="post" className="space-y-6 pb-52">
|
||||
<input name="action" value="edit" readOnly hidden />
|
||||
<div className="space-y-3">
|
||||
<div className="grid gap-3">
|
||||
<InputText ref={inputTitle} name="title" title={t("knowledgeBase.adminArticles.fields.title")} value={title} setValue={setTitle} required />
|
||||
<InputText
|
||||
name="slug"
|
||||
title={t("knowledgeBase.adminArticleSettings.fields.slug")}
|
||||
value={slug}
|
||||
setValue={setSlug}
|
||||
required
|
||||
hint={
|
||||
<button type="button" className="text-muted-foreground" onClick={() => setSlug(UrlUtils.slugify(title))}>
|
||||
{t("knowledgeBase.adminArticleSettings.actions.getFromTitle")}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<InputText
|
||||
name="description"
|
||||
title={t("knowledgeBase.adminArticles.fields.description")}
|
||||
value={description}
|
||||
setValue={setDescription}
|
||||
maxLength={300}
|
||||
// required
|
||||
/>
|
||||
<InputSelector
|
||||
name="knowledgeBaseId"
|
||||
title={t("knowledgeBase.adminArticleSettings.fields.knowledgeBase")}
|
||||
value={knowledgeBaseId}
|
||||
setValue={(e) => setKnowledgeBaseId(e?.toString() ?? "")}
|
||||
options={allKnowledgeBases.map((f) => {
|
||||
return {
|
||||
value: f.id,
|
||||
name: f.title,
|
||||
};
|
||||
})}
|
||||
/>
|
||||
<InputSelect
|
||||
name="language"
|
||||
title={t("knowledgeBase.adminArticleSettings.fields.language")}
|
||||
value={language}
|
||||
setValue={(e) => setLanguage(e?.toString() || "")}
|
||||
options={KnowledgeBaseUtils.getSupportedLanguageOptions(t)}
|
||||
/>
|
||||
<InputSelector
|
||||
name="categoryId"
|
||||
title={t("knowledgeBase.adminArticles.fields.category")}
|
||||
value={categoryId}
|
||||
setValue={(e) => setCategoryId(e?.toString())}
|
||||
options={allCategories
|
||||
.filter((f) => f.knowledgeBaseId === knowledgeBaseId && f.language === language)
|
||||
.map((f) => {
|
||||
return {
|
||||
value: f.id,
|
||||
name: f.title,
|
||||
};
|
||||
})}
|
||||
/>
|
||||
{category && category.sections.length > 0 && (
|
||||
<InputSelector
|
||||
name="sectionId"
|
||||
title={t("knowledgeBase.adminArticleSettings.fields.section")}
|
||||
value={sectionId}
|
||||
setValue={(e) => setSectionId(e?.toString())}
|
||||
options={category.sections.map((f) => {
|
||||
return {
|
||||
value: f.id,
|
||||
name: f.title,
|
||||
};
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
<InputText name="seoImage" title={t("knowledgeBase.adminBaseForm.fields.seoImage")} value={seoImage} setValue={setSeoImage} />
|
||||
|
||||
{seoImage && (
|
||||
<div>
|
||||
<img className="xl:border-border overflow-hidden rounded-lg shadow-xl xl:border-b" src={seoImage} alt={title} />
|
||||
</div>
|
||||
)}
|
||||
<InputCheckboxWithDescription
|
||||
name="isFeatured"
|
||||
title={t("knowledgeBase.adminArticles.fields.featured")}
|
||||
value={isFeatured}
|
||||
setValue={setIsFeatured}
|
||||
description={t("knowledgeBase.adminArticleSettings.descriptions.featured")}
|
||||
/>
|
||||
<div>
|
||||
{relatedArticles?.map((item, idx) => {
|
||||
return <input key={idx} type="hidden" name={`relatedArticles[]`} value={item} />;
|
||||
})}
|
||||
<InputCombobox
|
||||
name="relatedArticles"
|
||||
title={t("knowledgeBase.adminArticleSettings.fields.relatedArticles")}
|
||||
value={relatedArticles}
|
||||
onChange={(e) => setRelatedArticles(e as string[])}
|
||||
hint={
|
||||
<button type="button" className="text-muted-foreground" onClick={() => setRelatedArticles([])}>
|
||||
{t("shared.clear")}
|
||||
</button>
|
||||
}
|
||||
minDisplayCount={1}
|
||||
options={allArticles
|
||||
.filter((f) => f.id !== item.id && f.knowledgeBaseId === knowledgeBaseId && f.language === language)
|
||||
.map((f) => {
|
||||
let title = f.title;
|
||||
if (f.category) {
|
||||
title = `${f.title} (${f.category.title})`;
|
||||
}
|
||||
if (f.category && f.section) {
|
||||
title = `${f.title} (${f.category.title} / ${f.section.title})`;
|
||||
}
|
||||
return {
|
||||
name: title,
|
||||
value: f.id,
|
||||
};
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex justify-between space-x-2 sm:mt-6">
|
||||
<div>
|
||||
<ButtonSecondary disabled={navigation.state === "submitting"} type="button" className="w-full" onClick={onDelete} destructive>
|
||||
<div>{t("shared.delete")}</div>
|
||||
</ButtonSecondary>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<LoadingButton actionName="edit" type="submit" disabled={navigation.state === "submitting"}>
|
||||
{t("shared.save")}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
import { Form, useNavigate, useNavigation } from "react-router";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ButtonSecondary from "~/components/ui/buttons/ButtonSecondary";
|
||||
import LoadingButton from "~/components/ui/buttons/LoadingButton";
|
||||
import InputText, { RefInputText } from "~/components/ui/input/InputText";
|
||||
import UrlUtils from "~/utils/app/UrlUtils";
|
||||
import { KnowledgeBaseDto } from "~/modules/knowledgeBase/dtos/KnowledgeBaseDto";
|
||||
import EntityIcon from "~/components/layouts/icons/EntityIcon";
|
||||
import { KnowledgeBaseCategoryWithDetails } from "../../helpers/KbCategoryModelHelper";
|
||||
import KbSortArticles from "../articles/KbSortArticles";
|
||||
import { adminPath } from "~/utils/app/CanonicalUrlUtils";
|
||||
import QuestionMarkFilledIcon from "~/components/ui/icons/QuestionMarkFilledIcon";
|
||||
|
||||
export default function KbCategoryForm({
|
||||
knowledgeBase,
|
||||
language,
|
||||
item,
|
||||
onDelete,
|
||||
}: {
|
||||
knowledgeBase: KnowledgeBaseDto;
|
||||
language: string;
|
||||
item?: KnowledgeBaseCategoryWithDetails;
|
||||
onDelete?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const navigation = useNavigation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// const [showFilterModal, setShowFilterModal] = useState<{ item?: { type: FeatureFlagsFilterType; value: string | null }; idx?: number }>();
|
||||
|
||||
const mainInput = useRef<RefInputText>(null);
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
mainInput.current?.input.current?.focus();
|
||||
}, 100);
|
||||
}, []);
|
||||
|
||||
const [slug, setSlug] = useState(item?.slug || "");
|
||||
const [title, setTitle] = useState(item?.title || "");
|
||||
const [description, setDescription] = useState(item?.description || "");
|
||||
const [icon, setIcon] = useState(
|
||||
item?.icon || ""
|
||||
// `<svg stroke="currentColor" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5"> <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" /> </svg>`
|
||||
);
|
||||
// const [language, setLanguage] = useState(item?.language || "");
|
||||
const [seoImage, setSeoImage] = useState(item?.seoImage || "");
|
||||
|
||||
useEffect(() => {
|
||||
if (!item) {
|
||||
setSlug(UrlUtils.slugify(title.toLowerCase()));
|
||||
}
|
||||
}, [item, title]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Form method="post" className="inline-block w-full overflow-hidden p-1 text-left align-bottom sm:align-middle">
|
||||
<input type="hidden" name="action" value={item ? "edit" : "new"} hidden readOnly />
|
||||
<div className="space-y-2">
|
||||
<InputText ref={mainInput} autoFocus name="title" title={t("knowledgeBase.adminCategoryForm.fields.title")} value={title} setValue={setTitle} required />
|
||||
<InputText name="slug" title={t("knowledgeBase.adminCategoryForm.fields.slug")} value={slug} setValue={setSlug} required />
|
||||
<InputText name="description" title={t("knowledgeBase.adminCategoryForm.fields.description")} value={description} setValue={setDescription} />
|
||||
<InputText
|
||||
name="icon"
|
||||
title={t("knowledgeBase.adminCategoryForm.fields.icon")}
|
||||
value={icon}
|
||||
setValue={(e) => setIcon(e.toString() ?? "")}
|
||||
hint={t("knowledgeBase.adminCategoryForm.hints.icon")}
|
||||
button={
|
||||
<div className="absolute inset-y-0 right-0 flex py-0.5 pr-0.5 ">
|
||||
<kbd className="border-border text-muted-foreground bg-secondary inline-flex w-10 items-center justify-center rounded border px-1 text-center font-sans text-xs font-medium">
|
||||
{icon ? (
|
||||
<EntityIcon className="text-muted-foreground h-7 w-7" icon={icon} title={title} />
|
||||
) : (
|
||||
<QuestionMarkFilledIcon className="text-muted-foreground h-5 w-5" aria-hidden="true" />
|
||||
)}
|
||||
</kbd>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<InputText name="seoImage" title={t("knowledgeBase.adminCategoryForm.fields.seoImage")} value={seoImage} setValue={setSeoImage} hint={t("knowledgeBase.adminCategoryForm.hints.seoImage")} />
|
||||
{seoImage && (
|
||||
<div className="col-span-12">
|
||||
<img className="xl:border-border overflow-hidden rounded-lg shadow-xl xl:border-b" src={seoImage} alt={title} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item && <KbSortArticles items={item.articles.filter((f) => !f.sectionId).sort((a, b) => a.order - b.order)} />}
|
||||
</div>
|
||||
<div className="mt-5 flex justify-between space-x-2 sm:mt-6">
|
||||
<div>
|
||||
{onDelete && (
|
||||
<ButtonSecondary
|
||||
disabled={navigation.state === "submitting" || (item?.articles ?? []).length > 0}
|
||||
type="button"
|
||||
className="w-full"
|
||||
onClick={onDelete}
|
||||
destructive
|
||||
>
|
||||
<div>{t("shared.delete")}</div>
|
||||
</ButtonSecondary>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<ButtonSecondary onClick={() => navigate(adminPath(`knowledge-base/bases/${knowledgeBase.slug}/categories/${language}`, { mode: "host-aware" }))}>{t("shared.cancel")}</ButtonSecondary>
|
||||
<LoadingButton actionName={item ? "edit" : "new"} type="submit" disabled={navigation.state === "submitting"}>
|
||||
{t("shared.save")}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
import { Form, useNavigate, useNavigation } from "react-router";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ButtonSecondary from "~/components/ui/buttons/ButtonSecondary";
|
||||
import LoadingButton from "~/components/ui/buttons/LoadingButton";
|
||||
import InputText, { RefInputText } from "~/components/ui/input/InputText";
|
||||
import { KnowledgeBaseDto } from "~/modules/knowledgeBase/dtos/KnowledgeBaseDto";
|
||||
import { KnowledgeBaseCategorySectionWithDetails } from "~/modules/knowledgeBase/db/kbCategorySections.db.server";
|
||||
import { KnowledgeBaseCategoryWithDetails } from "../../helpers/KbCategoryModelHelper";
|
||||
import KbSortArticles from "../articles/KbSortArticles";
|
||||
import { adminPath } from "~/utils/app/CanonicalUrlUtils";
|
||||
|
||||
export default function KbCategorySectionForm({
|
||||
knowledgeBase,
|
||||
category,
|
||||
language,
|
||||
item,
|
||||
onDelete,
|
||||
}: {
|
||||
knowledgeBase: KnowledgeBaseDto;
|
||||
category: KnowledgeBaseCategoryWithDetails;
|
||||
language: string;
|
||||
item?: KnowledgeBaseCategorySectionWithDetails;
|
||||
onDelete?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const navigation = useNavigation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// const [showFilterModal, setShowFilterModal] = useState<{ item?: { type: FeatureFlagsFilterType; value: string | null }; idx?: number }>();
|
||||
|
||||
const mainInput = useRef<RefInputText>(null);
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
mainInput.current?.input.current?.focus();
|
||||
}, 100);
|
||||
}, []);
|
||||
|
||||
const [title, setTitle] = useState(item?.title || "");
|
||||
const [description, setDescription] = useState(item?.description || "");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Form method="post" className="inline-block w-full overflow-hidden p-1 text-left align-bottom sm:align-middle">
|
||||
<input type="hidden" name="action" value={item ? "edit" : "new"} hidden readOnly />
|
||||
<div className="space-y-2">
|
||||
<InputText ref={mainInput} autoFocus name="title" title={t("knowledgeBase.adminCategorySectionForm.fields.title")} value={title} setValue={setTitle} required />
|
||||
<InputText name="description" title={t("knowledgeBase.adminCategorySectionForm.fields.description")} value={description} setValue={setDescription} />
|
||||
{item && <KbSortArticles items={item.articles.sort((a, b) => a.order - b.order)} />}
|
||||
</div>
|
||||
<div className="mt-5 flex justify-between space-x-2 sm:mt-6">
|
||||
<div>
|
||||
{onDelete && (
|
||||
<ButtonSecondary
|
||||
disabled={navigation.state === "submitting" || (item?.articles ?? []).length > 0}
|
||||
type="button"
|
||||
className="w-full"
|
||||
onClick={onDelete}
|
||||
destructive
|
||||
>
|
||||
<div>{t("shared.delete")}</div>
|
||||
</ButtonSecondary>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<ButtonSecondary onClick={() => navigate(adminPath(`knowledge-base/bases/${knowledgeBase.slug}/categories/${language}`, { mode: "host-aware" }))}>{t("shared.cancel")}</ButtonSecondary>
|
||||
<LoadingButton actionName={item ? "edit" : "new"} type="submit" disabled={navigation.state === "submitting"}>
|
||||
{t("shared.save")}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
import OrderIndexButtons from "~/components/ui/sort/OrderIndexButtons";
|
||||
import TableSimple from "~/components/ui/tables/TableSimple";
|
||||
import { KbNavLinkDto } from "~/modules/knowledgeBase/dtos/KbNavLinkDto";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PlusIcon } from "~/components/ui/icons/vendor";
|
||||
|
||||
export default function KbNavLinksTable({
|
||||
items,
|
||||
setItems,
|
||||
}: {
|
||||
items: KbNavLinkDto[];
|
||||
setItems: React.Dispatch<
|
||||
React.SetStateAction<
|
||||
{
|
||||
name: string;
|
||||
href: string;
|
||||
order: number;
|
||||
}[]
|
||||
>
|
||||
>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1 flex items-center justify-between space-x-2 text-xs">
|
||||
<label className="text-muted-foreground font-medium">{t("knowledgeBase.adminBaseForm.navLinks.title")}</label>
|
||||
<button type="button" onClick={() => setItems([])} className="text-muted-foreground hover:text-foreground/80">
|
||||
{t("shared.clear")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="">
|
||||
<TableSimple
|
||||
items={items.sort((a, b) => a.order - b.order)}
|
||||
headers={[
|
||||
{
|
||||
name: "order",
|
||||
title: "",
|
||||
value: (item, idx) => (
|
||||
<OrderIndexButtons
|
||||
idx={idx}
|
||||
items={items.map((f, idx) => {
|
||||
return {
|
||||
idx: idx,
|
||||
order: f.order,
|
||||
};
|
||||
})}
|
||||
onChange={(newItems) => {
|
||||
setItems(
|
||||
newItems.map((f, i) => {
|
||||
return { ...items[i], order: f.order };
|
||||
})
|
||||
);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "title",
|
||||
title: t("knowledgeBase.adminBaseForm.navLinks.columns.title"),
|
||||
value: (item) => item.name,
|
||||
editable: () => true,
|
||||
setValue: (value, idx) => setItems((e) => e.map((item, i) => (i === idx ? { ...item, name: value } : item))),
|
||||
},
|
||||
{
|
||||
name: "href",
|
||||
title: t("knowledgeBase.adminBaseForm.navLinks.columns.link"),
|
||||
value: (item) => item.href,
|
||||
editable: () => true,
|
||||
setValue: (value, idx) => setItems((e) => e.map((item, i) => (i === idx ? { ...item, href: value } : item))),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setItems([...items, { name: t("knowledgeBase.adminBaseForm.navLinks.defaultName", { index: items.length + 1 }), href: "/", order: items.length + 1 }]);
|
||||
}}
|
||||
className="border-border text-muted-foreground focus:text-foreground bg-background hover:bg-secondary/90 mt-2 flex items-center space-x-1 rounded-md border px-2 py-1 text-xs focus:ring-3 focus:ring-gray-300 focus:ring-offset-1"
|
||||
>
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
<span className="font-medium uppercase">{t("shared.add")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
import { Form, useNavigate, useNavigation } from "react-router";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Colors } from "~/application/enums/shared/Colors";
|
||||
import ButtonSecondary from "~/components/ui/buttons/ButtonSecondary";
|
||||
import LoadingButton from "~/components/ui/buttons/LoadingButton";
|
||||
import InputCheckboxWithDescription from "~/components/ui/input/InputCheckboxWithDescription";
|
||||
import InputCombobox from "~/components/ui/input/InputCombobox";
|
||||
import InputSelect from "~/components/ui/input/InputSelect";
|
||||
import InputSelector from "~/components/ui/input/InputSelector";
|
||||
import InputText, { RefInputText } from "~/components/ui/input/InputText";
|
||||
import { KnowledgeBaseDto } from "~/modules/knowledgeBase/dtos/KnowledgeBaseDto";
|
||||
import UrlUtils from "~/utils/app/UrlUtils";
|
||||
import { getColors } from "~/utils/shared/ColorUtils";
|
||||
import KbNavLinksTable from "./KbNavLinksTable";
|
||||
import KnowledgeBaseUtils from "~/modules/knowledgeBase/utils/KnowledgeBaseUtils";
|
||||
import { logoLight, logoDark } from "~/assets/brand/default";
|
||||
import clsx from "clsx";
|
||||
import ColorBackgroundUtils from "~/utils/shared/colors/ColorBackgroundUtils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { adminPath } from "~/utils/app/CanonicalUrlUtils";
|
||||
|
||||
export default function KnowledgeBaseForm({ item, onDelete }: { item?: KnowledgeBaseDto; onDelete?: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const navigation = useNavigation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// const [showFilterModal, setShowFilterModal] = useState<{ item?: { type: FeatureFlagsFilterType; value: string | null }; idx?: number }>();
|
||||
|
||||
const mainInput = useRef<RefInputText>(null);
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
mainInput.current?.input.current?.focus();
|
||||
}, 100);
|
||||
}, []);
|
||||
|
||||
const [basePath, setBasePath] = useState<string | number | undefined>(item ? item.basePath : "/");
|
||||
const [slug, setSlug] = useState(item ? item.slug : "docs");
|
||||
const [title, setTitle] = useState(item?.title || "");
|
||||
const [description, setDescription] = useState(item?.description || "");
|
||||
const [defaultLanguage, setDefaultLanguage] = useState<string | undefined>(item?.defaultLanguage || KnowledgeBaseUtils.defaultLanguage);
|
||||
const [layout, setLayout] = useState<"list" | "articles" | "grid">(item?.layout || ("list" as any));
|
||||
const [color, setColor] = useState(item?.color || Colors.BLUE);
|
||||
const [enabled, setEnabled] = useState(item?.enabled ?? false);
|
||||
// const [metatags, setMetatags] = useState<MetaTagsDto>(item?.metatags || []);
|
||||
const [languages, setLanguages] = useState<string[]>(item?.languages || [KnowledgeBaseUtils.defaultLanguage]);
|
||||
const [logo, setLogo] = useState<"light" | "dark" | string>(item?.logo || "dark");
|
||||
const [seoImage, setSeoImage] = useState(item?.seoImage || "");
|
||||
const [links, setLinks] = useState<{ name: string; href: string; order: number }[]>(item?.links || []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!item) {
|
||||
setSlug(UrlUtils.slugify(title.toLowerCase()));
|
||||
}
|
||||
}, [item, title]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Form method="post" className="inline-block w-full overflow-hidden p-1 text-left align-bottom sm:align-middle">
|
||||
<input type="hidden" name="action" value={item ? "edit" : "new"} hidden readOnly />
|
||||
{links.map((item, index) => {
|
||||
return <input type="hidden" name="links[]" value={JSON.stringify(item)} key={index} hidden readOnly />;
|
||||
})}
|
||||
|
||||
<div className="space-y-2">
|
||||
<InputText ref={mainInput} autoFocus name="title" title={t("knowledgeBase.adminBaseForm.fields.title")} value={title} setValue={setTitle} required />
|
||||
<InputSlug setBasePath={setBasePath} />
|
||||
<InputText name="description" title={t("knowledgeBase.adminBaseForm.fields.description")} value={description} setValue={setDescription} />
|
||||
<div>
|
||||
{languages?.map((item, idx) => {
|
||||
return <input key={idx} type="hidden" name={`languages[]`} value={item} />;
|
||||
})}
|
||||
<InputCombobox
|
||||
name="languages"
|
||||
title={t("knowledgeBase.adminBaseForm.fields.languages")}
|
||||
value={languages}
|
||||
onChange={(e) => setLanguages(e as string[])}
|
||||
options={KnowledgeBaseUtils.getSupportedLanguageOptions(t)}
|
||||
/>
|
||||
</div>
|
||||
<InputSelect
|
||||
name="defaultLanguage"
|
||||
title={t("knowledgeBase.adminBaseForm.fields.defaultLanguage")}
|
||||
value={defaultLanguage}
|
||||
setValue={(e) => setDefaultLanguage(e?.toString() || "en")}
|
||||
options={languages.map((item) => {
|
||||
return {
|
||||
value: item,
|
||||
name: KnowledgeBaseUtils.getLanguageName(item, t) || item,
|
||||
};
|
||||
})}
|
||||
/>
|
||||
<InputSelect
|
||||
name="layout"
|
||||
title={t("knowledgeBase.adminBaseForm.fields.layout")}
|
||||
value={layout}
|
||||
setValue={(e) => setLayout((e?.toString() as any) || "list")}
|
||||
options={[
|
||||
{
|
||||
value: "list",
|
||||
name: t("knowledgeBase.layouts.list"),
|
||||
},
|
||||
{
|
||||
value: "articles",
|
||||
name: t("knowledgeBase.layouts.articles"),
|
||||
},
|
||||
{
|
||||
value: "grid",
|
||||
name: t("knowledgeBase.layouts.grid"),
|
||||
},
|
||||
{
|
||||
value: "docs",
|
||||
name: t("knowledgeBase.layouts.docs"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<InputSelector
|
||||
name="color"
|
||||
title={t("knowledgeBase.adminBaseForm.fields.color")}
|
||||
value={color}
|
||||
setValue={(e) => setColor(e as number)}
|
||||
withColors={true}
|
||||
options={
|
||||
getColors().map((color) => {
|
||||
return {
|
||||
name: t(`app.shared.colors.${Colors[color ?? 0]}`),
|
||||
value: color,
|
||||
color,
|
||||
};
|
||||
}) ?? []
|
||||
}
|
||||
/>
|
||||
|
||||
<InputText
|
||||
name="logo"
|
||||
title={t("knowledgeBase.adminBaseForm.fields.logo")}
|
||||
value={logo}
|
||||
setValue={setLogo}
|
||||
hint={t("knowledgeBase.adminBaseForm.hints.logo")}
|
||||
button={
|
||||
<div className="absolute inset-y-0 right-0 flex py-0.5 pr-0.5 ">
|
||||
<kbd
|
||||
className={clsx(
|
||||
"border-border text-muted-foreground inline-flex w-auto items-center justify-center rounded border px-1 text-center font-sans text-xs font-medium",
|
||||
ColorBackgroundUtils.getBg700(color)
|
||||
)}
|
||||
>
|
||||
{logo === "light" ? (
|
||||
<img className="h-7 w-auto" src={logoLight} alt={t("knowledgeBase.adminBaseForm.preview.logoAlt")} />
|
||||
) : logo === "dark" ? (
|
||||
<img className="h-7 w-auto" src={logoDark} alt={t("knowledgeBase.adminBaseForm.preview.logoAlt")} />
|
||||
) : logo.startsWith("http") ? (
|
||||
<img className="h-7 w-auto" src={logo} alt={t("knowledgeBase.adminBaseForm.preview.logoAlt")} />
|
||||
) : (
|
||||
<div className="italic text-white">{t("knowledgeBase.adminBaseForm.preview.invalidLogo")}</div>
|
||||
)}
|
||||
</kbd>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<InputText name="seoImage" title={t("knowledgeBase.adminBaseForm.fields.seoImage")} value={seoImage} setValue={setSeoImage} hint={t("knowledgeBase.adminBaseForm.hints.url")} />
|
||||
{seoImage && (
|
||||
<div className="col-span-12">
|
||||
<img className="xl:border-border overflow-hidden rounded-lg shadow-xl xl:border-b" src={seoImage} alt={title} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<InputCheckboxWithDescription
|
||||
name="enabled"
|
||||
title={t("knowledgeBase.adminBaseForm.fields.enabled")}
|
||||
value={enabled}
|
||||
setValue={setEnabled}
|
||||
description={t("knowledgeBase.adminBaseForm.descriptions.enabled")}
|
||||
/>
|
||||
|
||||
<KbNavLinksTable items={links} setItems={setLinks} />
|
||||
</div>
|
||||
<div className="mt-5 flex justify-between space-x-2 sm:mt-6">
|
||||
<div>
|
||||
{onDelete && (
|
||||
<ButtonSecondary disabled={navigation.state === "submitting"} type="button" className="w-full" onClick={onDelete} destructive>
|
||||
<div>{t("shared.delete")}</div>
|
||||
</ButtonSecondary>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<ButtonSecondary onClick={() => navigate(adminPath("knowledge-base/bases", { mode: "host-aware" }))}>{t("shared.cancel")}</ButtonSecondary>
|
||||
<LoadingButton actionName={item ? "edit" : "new"} type="submit" disabled={navigation.state === "submitting"}>
|
||||
{t("shared.save")}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InputSlug({
|
||||
setBasePath,
|
||||
}: {
|
||||
setBasePath: React.Dispatch<React.SetStateAction<string | number | undefined>>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
setBasePath("/");
|
||||
}, [setBasePath]);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<input type="hidden" name="basePath" value={"/"} hidden readOnly />
|
||||
<input type="hidden" name="slug" value={"docs"} hidden readOnly />
|
||||
<InputText title={t("knowledgeBase.adminBaseForm.fields.url")} value={UrlUtils.join("/", "docs")} setValue={() => {}} disabled />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
|
||||
const sourceFiles = [
|
||||
"apps/web/app/modules/knowledgeBase/components/bases/KnowledgeBaseForm.tsx",
|
||||
"apps/web/app/modules/knowledgeBase/components/bases/KbCategoryForm.tsx",
|
||||
"apps/web/app/modules/knowledgeBase/components/bases/KbCategorySectionForm.tsx",
|
||||
"apps/web/app/modules/knowledgeBase/components/bases/KbArticleSettingsForm.tsx",
|
||||
"apps/web/app/modules/knowledgeBase/components/bases/KbNavLinksTable.tsx",
|
||||
"apps/web/app/modules/knowledgeBase/components/categories/KbCategoriesGrid.tsx",
|
||||
"apps/web/app/modules/knowledgeBase/components/categories/KbCategoriesList.tsx",
|
||||
"apps/web/app/modules/knowledgeBase/components/templates/PreviewKbsTemplate.tsx",
|
||||
"apps/web/app/routes/admin/knowledge-base.tsx",
|
||||
"apps/web/app/routes/admin/knowledge-base/index.tsx",
|
||||
"apps/web/app/routes/admin/knowledge-base/bases.tsx",
|
||||
"apps/web/app/routes/admin/knowledge-base/bases.import.tsx",
|
||||
"apps/web/app/routes/admin/knowledge-base/danger.tsx",
|
||||
"apps/web/app/routes/admin/knowledge-base/articles.tsx",
|
||||
"apps/web/app/routes/admin/knowledge-base/bases.$slug/categories.tsx",
|
||||
"apps/web/app/routes/admin/knowledge-base/bases.$slug/categories.$lang.tsx",
|
||||
"apps/web/app/routes/admin/knowledge-base/bases.$slug/articles.tsx",
|
||||
"apps/web/app/routes/admin/knowledge-base/bases.$slug/articles.$lang.tsx",
|
||||
"apps/web/app/routes/admin/knowledge-base/bases.$slug/articles.$lang.$id.tsx",
|
||||
"apps/web/app/routes/admin/knowledge-base/bases.$slug/articles.$lang.$id.edit.tsx",
|
||||
];
|
||||
|
||||
describe("knowledge base authoring i18n", () => {
|
||||
it("uses semantic keys for visible admin authoring labels", () => {
|
||||
const sources = sourceFiles.map((file) => fs.readFileSync(path.join(repoRoot, file), "utf8")).join("\n");
|
||||
|
||||
expect(sources).toContain("knowledgeBase.adminBaseForm.fields.title");
|
||||
expect(sources).toContain("knowledgeBase.adminCategoryForm.fields.title");
|
||||
expect(sources).toContain("knowledgeBase.adminCategorySectionForm.fields.description");
|
||||
expect(sources).toContain("knowledgeBase.adminCategories.confirmDelete.category.title");
|
||||
expect(sources).toContain("knowledgeBase.adminCategories.counts.articles");
|
||||
expect(sources).toContain("knowledgeBase.adminCategories.counts.sections");
|
||||
expect(sources).toContain("formatters.number(item.sections.length)");
|
||||
expect(sources).toContain("formatters.number(category.articles.filter((f) => f.sectionId === item.id).length)");
|
||||
expect(sources).toContain("knowledgeBase.adminBaseForm.navLinks.defaultName");
|
||||
expect(sources).toContain("knowledgeBase.adminArticleSettings.fields.relatedArticles");
|
||||
expect(sources).toContain("knowledgeBase.adminArticleDetail.actions.publish");
|
||||
expect(sources).toContain("knowledgeBase.adminBases.metaTitle");
|
||||
expect(sources).toContain("knowledgeBase.adminBases.actions.categories");
|
||||
expect(sources).toContain("knowledgeBase.adminBases.counts.articles");
|
||||
expect(sources).toContain("formattedCount: formatters.number(i.count.articles)");
|
||||
expect(sources).toContain("value: (i) => formatters.number(i.count.views)");
|
||||
expect(sources).toContain("knowledgeBase.adminBases.empty.title");
|
||||
expect(sources).toContain("knowledgeBase.adminImport.title");
|
||||
expect(sources).toContain("knowledgeBase.adminImport.configuration");
|
||||
expect(sources).toContain("knowledgeBase.adminDanger.resetAllData");
|
||||
expect(sources).toContain("knowledgeBase.adminDanger.resetAllDataDescription");
|
||||
expect(sources).toContain("knowledgeBase.authoring.metaTitle");
|
||||
expect(sources).toContain("knowledgeBase.authoring.navigation.overview");
|
||||
expect(sources).toContain("knowledgeBase.authoring.breadcrumbs.knowledgeBases");
|
||||
expect(sources).toContain("knowledgeBase.authoring.categories.count");
|
||||
expect(sources).toContain("knowledgeBase.authoring.articles.count");
|
||||
expect(sources).toContain("knowledgeBase.category.articleCount");
|
||||
expect(sources).toContain("knowledgeBase.authoring.actions.saveDraft");
|
||||
expect(sources).toContain("knowledgeBase.authoring.articleSettings.title");
|
||||
expect(sources).toContain("knowledgeBase.templates.preview.headings.knowledgeBases");
|
||||
expect(sources).toContain("knowledgeBase.templates.preview.columns.knowledgeBase");
|
||||
expect(sources).toContain("KnowledgeBaseUtils.getSupportedLanguageOptions(t)");
|
||||
expect(sources).toContain("KnowledgeBaseUtils.getLanguageName(item, t)");
|
||||
expect(sources).toContain("KnowledgeBaseUtils.getLanguageName(f, t)");
|
||||
expect(sources).toContain("KnowledgeBaseUtils.getLanguageName(params.lang!, t)");
|
||||
expect(sources).toContain("KnowledgeBaseUtils.getLanguageName(language, t)");
|
||||
expect(sources).toContain("QuestionMarkFilledIcon");
|
||||
expect(sources).not.toContain("ui.autogenerated");
|
||||
expect(sources).not.toContain("KnowledgeBaseUtils.getLanguageName(f)</div>");
|
||||
expect(sources).not.toContain("KnowledgeBaseUtils.getLanguageName(f)}");
|
||||
expect(sources).not.toContain("KnowledgeBaseUtils.getLanguageName(params.lang!)");
|
||||
expect(sources).not.toContain("KnowledgeBaseUtils.getLanguageName(language))");
|
||||
expect(sources).not.toContain('text-red-600">?</span>');
|
||||
expect(sources).not.toContain("adminCategories.counts.sections\", { count: item.sections.length })");
|
||||
expect(sources).not.toContain("adminCategories.counts.articles\", { count: category.articles.filter((f) => f.sectionId === item.id).length })");
|
||||
});
|
||||
|
||||
it("keeps the remaining authoring strings out of JSX literals", () => {
|
||||
const forbidden = [
|
||||
'{"Title"}',
|
||||
'{"Layout"}',
|
||||
'{"Color"}',
|
||||
'{"Logo"}',
|
||||
'{"Enabled"}',
|
||||
'{"Delete"}',
|
||||
'{"Cancel"}',
|
||||
'{"Save"}',
|
||||
'{"Language"}',
|
||||
'{"Category"}',
|
||||
'{"Section"}',
|
||||
'{"Featured"}',
|
||||
'{"Nav links"}',
|
||||
'{"Clear"}',
|
||||
'{"Add"}',
|
||||
">Settings<",
|
||||
">Preview<",
|
||||
">Publish<",
|
||||
">Import<",
|
||||
">Danger<",
|
||||
">article<",
|
||||
">articles<",
|
||||
"Reset all Knowledge Base data?",
|
||||
"Delete all Knowledge Base data",
|
||||
"Get started by creating a new knowledge base.",
|
||||
];
|
||||
|
||||
for (const file of sourceFiles) {
|
||||
const source = fs.readFileSync(path.join(repoRoot, file), "utf8");
|
||||
for (const literal of forbidden) {
|
||||
expect(source).not.toContain(literal);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
import type { KnowledgeBaseDto } from "../../dtos/KnowledgeBaseDto";
|
||||
import type { KbCategoryDto } from "../../dtos/KbCategoryDto";
|
||||
import KbCategoriesList from "./KbCategoriesList";
|
||||
import KbCategoriesGrid from "./KbCategoriesGrid";
|
||||
import KbCategoriesTopArticles from "./KbCategoriesTopArticles";
|
||||
import EmptyState from "~/components/ui/emptyState/EmptyState";
|
||||
import { Fragment } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function KbCategories({ items, kb }: { items: KbCategoryDto[]; kb: KnowledgeBaseDto }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div>
|
||||
{items.length === 0 ? (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-2xl font-bold">{t("knowledgeBase.category.plural")}</h2>
|
||||
<EmptyState className="bg-background" captions={{ thereAreNo: t("knowledgeBase.category.empty") }} />
|
||||
</div>
|
||||
) : (
|
||||
<Fragment>
|
||||
{kb.layout === "list" ? (
|
||||
<KbCategoriesList kb={kb} items={items} />
|
||||
) : kb.layout === "articles" ? (
|
||||
<KbCategoriesTopArticles kb={kb} items={items} />
|
||||
) : kb.layout === "grid" ? (
|
||||
<KbCategoriesGrid kb={kb} items={items} columns={3} />
|
||||
) : kb.layout === "docs" ? (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-2xl font-bold">{t("knowledgeBase.category.plural")}</h2>
|
||||
<KbCategoriesGrid kb={kb} items={items} columns={3} />
|
||||
</div>
|
||||
) : null}
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
import { Link } from "react-router";
|
||||
import clsx from "clsx";
|
||||
import type { KbCategoryDto } from "~/modules/knowledgeBase/dtos/KbCategoryDto";
|
||||
import { KnowledgeBaseDto } from "../../dtos/KnowledgeBaseDto";
|
||||
import ColorTextUtils from "~/utils/shared/colors/ColorTextUtils";
|
||||
import ColorGroupHoverUtils from "~/utils/shared/colors/ColorGroupHoverUtils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocaleFormatters } from "~/locale/format";
|
||||
import { sanitizeIconSvg } from "~/utils/html-sanitizer";
|
||||
|
||||
export default function KbCategoriesGrid({ kb, items, columns }: { kb: KnowledgeBaseDto; items: KbCategoryDto[]; columns: number }) {
|
||||
const { t } = useTranslation();
|
||||
const formatters = useLocaleFormatters();
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"grid grid-cols-1 gap-4",
|
||||
columns === 2 && "md:grid-cols-2",
|
||||
columns === 3 && "md:grid-cols-3",
|
||||
columns === 4 && "md:grid-cols-4",
|
||||
columns === 5 && "md:grid-cols-5",
|
||||
columns === 6 && "md:grid-cols-6"
|
||||
)}
|
||||
>
|
||||
{items.map((item) => {
|
||||
return (
|
||||
<div key={item.title} className="border-border bg-background hover:bg-secondary hover:border-secondary-foreground group rounded-md border">
|
||||
<Link to={item.href} className="w-full">
|
||||
<div className="flex flex-col space-y-3 p-6">
|
||||
<div className="shrink-0">
|
||||
{item.icon.startsWith("<svg") ? (
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: sanitizeIconSvg(
|
||||
item.icon,
|
||||
clsx("h-8 w-8", ColorTextUtils.getText500(kb.color), ColorGroupHoverUtils.getText700(kb.color))
|
||||
),
|
||||
}}
|
||||
/>
|
||||
) : item.icon.includes("http") ? (
|
||||
<img className="h-8 w-8" src={item.icon} alt={item.title} />
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-8">
|
||||
<div className="flex w-full flex-col">
|
||||
<div className="font-bold">{item.title}</div>
|
||||
<div className="text-muted-foreground mt-1 text-sm">{item.description}</div>
|
||||
<div className="mt-6 flex items-center justify-between space-x-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="text-sm">
|
||||
{t("knowledgeBase.category.articleCount", { count: item.articles.length, formattedCount: formatters.number(item.articles.length) })}
|
||||
</div>
|
||||
</div>
|
||||
{/* <div className="text-sm text-muted-foreground">{item.version}</div> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
import { Link } from "react-router";
|
||||
import clsx from "clsx";
|
||||
|
||||
import type { KbCategoryDto } from "~/modules/knowledgeBase/dtos/KbCategoryDto";
|
||||
import ColorTextUtils from "~/utils/shared/colors/ColorTextUtils";
|
||||
import { KnowledgeBaseDto } from "../../dtos/KnowledgeBaseDto";
|
||||
import ColorGroupHoverUtils from "~/utils/shared/colors/ColorGroupHoverUtils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocaleFormatters } from "~/locale/format";
|
||||
|
||||
export default function KbCategoriesList({ kb, items }: { kb: KnowledgeBaseDto; items: KbCategoryDto[] }) {
|
||||
const { t } = useTranslation();
|
||||
const formatters = useLocaleFormatters();
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-2xl font-bold">{t("knowledgeBase.category.plural")}</h2>
|
||||
<div className={clsx("grid grid-cols-1 gap-4")}>
|
||||
{items.map((item) => {
|
||||
return (
|
||||
<div key={item.title} className="border-border bg-background hover:bg-secondary hover:border-secondary-foreground group rounded-md border">
|
||||
<Link to={item.href} className="w-full">
|
||||
<div className="flex items-center space-x-8 p-6">
|
||||
<div className="shrink-0">
|
||||
{item.icon.startsWith("<svg") ? (
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html:
|
||||
item.icon.replace(
|
||||
"<svg",
|
||||
`<svg class='${clsx("h-12 w-12", ColorTextUtils.getText500(kb.color), ColorGroupHoverUtils.getText700(kb.color))}'`
|
||||
) ?? "",
|
||||
}}
|
||||
/>
|
||||
) : item.icon.includes("http") ? (
|
||||
<img className="h-12 w-12" src={item.icon} alt={item.title} />
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex w-full flex-col">
|
||||
<div className="font-bold">{item.title}</div>
|
||||
<div className="text-muted-foreground mt-1 text-sm">{item.description}</div>
|
||||
<div className="mt-6 flex items-center justify-between space-x-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="text-sm">
|
||||
{t("knowledgeBase.category.articleCount", { count: item.articles.length, formattedCount: formatters.number(item.articles.length) })}
|
||||
</div>
|
||||
</div>
|
||||
{/* <div className="text-sm text-muted-foreground">{item.version}</div> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
import { Link } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import clsx from "clsx";
|
||||
import type { KbCategoryDto } from "../../dtos/KbCategoryDto";
|
||||
import { KnowledgeBaseDto } from "../../dtos/KnowledgeBaseDto";
|
||||
import ColorTextUtils from "~/utils/shared/colors/ColorTextUtils";
|
||||
import ColorGroupHoverUtils from "~/utils/shared/colors/ColorGroupHoverUtils";
|
||||
|
||||
export default function KbCategoriesTopArticles({ kb, items }: { kb: KnowledgeBaseDto; items: KbCategoryDto[] }) {
|
||||
const { t } = useTranslation();
|
||||
function getTopArticles(item: KbCategoryDto) {
|
||||
return item.articles.slice(0, 3);
|
||||
}
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{items.map((item) => {
|
||||
return (
|
||||
<div key={item.slug} className="space-y-4">
|
||||
<div className="flex items-baseline justify-between space-x-2">
|
||||
<div className="flex items-center space-x-4">
|
||||
{item.icon.startsWith("<svg") ? (
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html:
|
||||
item.icon.replace(
|
||||
"<svg",
|
||||
`<svg class='${clsx("h-6 w-6", ColorTextUtils.getText500(kb.color), ColorGroupHoverUtils.getText700(kb.color))}'`
|
||||
) ?? "",
|
||||
}}
|
||||
/>
|
||||
) : item.icon.includes("http") ? (
|
||||
<img className="h-6 w-6" src={item.icon} alt={item.title} />
|
||||
) : null}
|
||||
<Link to={item.href} className="hover:underline">
|
||||
<h2 className="text-2xl font-bold">{item.title}</h2>
|
||||
</Link>
|
||||
</div>
|
||||
<Link to={item.href} className="text-muted-foreground text-sm hover:underline">
|
||||
{t("knowledgeBase.category.viewAll")}
|
||||
</Link>
|
||||
</div>
|
||||
<div className={clsx("grid grid-cols-3 gap-4")}>
|
||||
{getTopArticles(item).map((item) => {
|
||||
return (
|
||||
<div key={item.title} className="border-border bg-background hover:bg-secondary hover:border-secondary-foreground group rounded-md border">
|
||||
<Link to={item.href} className="w-full">
|
||||
<div className="flex items-center space-x-8 p-6">
|
||||
<div className="flex w-full flex-col space-y-1">
|
||||
<div className="group-hover:text-foreground/80 font-medium">{item.title}</div>
|
||||
<div className="text-muted-foreground text-sm">{item.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
import type { KnowledgeBaseDto } from "../../dtos/KnowledgeBaseDto";
|
||||
import type { KbCategoryDto } from "../../dtos/KbCategoryDto";
|
||||
import BreadcrumbSimple from "~/components/ui/breadcrumbs/BreadcrumbSimple";
|
||||
import clsx from "clsx";
|
||||
import { Link, useParams } from "react-router";
|
||||
import KbArticlesBySection from "../articles/KbArticlesBySection";
|
||||
import KnowledgeBaseUtils from "../../utils/KnowledgeBaseUtils";
|
||||
import ColorTextUtils from "~/utils/shared/colors/ColorTextUtils";
|
||||
import ColorGroupHoverUtils from "~/utils/shared/colors/ColorGroupHoverUtils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { sanitizeIconSvg } from "~/utils/html-sanitizer";
|
||||
|
||||
export default function KbCategory({ kb, item, allCategories }: { kb: KnowledgeBaseDto; item: KbCategoryDto; allCategories: KbCategoryDto[] }) {
|
||||
const { t } = useTranslation();
|
||||
const params = useParams();
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<BreadcrumbSimple
|
||||
menu={[
|
||||
{
|
||||
title: kb.title,
|
||||
routePath: KnowledgeBaseUtils.getKbUrl({ kb, params }),
|
||||
},
|
||||
{
|
||||
title: item.title ?? "",
|
||||
routePath: item.href,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col space-y-6">
|
||||
<div className="flex items-center space-x-8">
|
||||
<div className="flex w-full flex-col">
|
||||
<div className="flex items-center space-x-3">
|
||||
{item.icon && (
|
||||
<div className="shrink-0">
|
||||
{item.icon.startsWith("<svg") ? (
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: sanitizeIconSvg(
|
||||
item.icon,
|
||||
clsx("h-7 w-7", ColorTextUtils.getText500(kb.color), ColorGroupHoverUtils.getText700(kb.color))
|
||||
),
|
||||
}}
|
||||
/>
|
||||
) : item.icon.includes("http") ? (
|
||||
<img className="h-7 w-7" src={item.icon} alt={item.title} />
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xl font-bold md:text-2xl">{item.title}</div>
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-2 font-normal">{item.description}</div>
|
||||
{/* <div className="mt-6 flex items-center space-x-2 justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="text-sm">
|
||||
{item.articles.length} <span>{item.articles.length === 1 ? "article" : "articles"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">{item.version}</div>
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-border border-b"></div>
|
||||
|
||||
<div className="grid grid-cols-12 gap-8">
|
||||
<div className="col-span-3 hidden space-y-3 md:block">
|
||||
<div className="text font-bold">{t("knowledgeBase.category.plural")}</div>
|
||||
<div className="space-y-1">
|
||||
{allCategories.map((category) => {
|
||||
return (
|
||||
<Link to={category.href} key={category.title}>
|
||||
<div
|
||||
className={clsx(
|
||||
"group rounded-md border border-transparent p-2",
|
||||
category.slug === item.slug ? "bg-secondary font-bold" : "hover:bg-secondary"
|
||||
)}
|
||||
>
|
||||
<div className="text-sm">{category.title}</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-12 pt-2 md:col-span-9">
|
||||
<KbArticlesBySection kb={kb} item={item} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const sourceFiles = [
|
||||
"apps/web/app/modules/knowledgeBase/components/KbHeader.tsx",
|
||||
"apps/web/app/modules/knowledgeBase/components/KbFeaturedArticles.tsx",
|
||||
"apps/web/app/modules/knowledgeBase/components/articles/KbArticlesBySection.tsx",
|
||||
"apps/web/app/modules/knowledgeBase/components/articles/KbArticleContent.tsx",
|
||||
"apps/web/app/modules/knowledgeBase/components/categories/KbCategoriesGrid.tsx",
|
||||
"apps/web/app/modules/knowledgeBase/components/categories/KbCategoriesList.tsx",
|
||||
"apps/web/app/modules/knowledgeBase/components/layouts/KbDocsHeader.tsx",
|
||||
];
|
||||
const publicRouteFiles = [
|
||||
"apps/web/app/modules/knowledgeBase/routes/api/KbRoutes.Article.Api.tsx",
|
||||
"apps/web/app/modules/knowledgeBase/routes/views/KbRoutes.Article.View.tsx",
|
||||
"apps/web/app/modules/knowledgeBase/routes/views/KbRoutes.Category.View.tsx",
|
||||
];
|
||||
|
||||
describe("knowledge base public i18n", () => {
|
||||
it("keeps public article empty states and dates locale-aware", () => {
|
||||
const sources = sourceFiles.map((file) => fs.readFileSync(path.join(repoRoot, file), "utf8")).join("\n");
|
||||
|
||||
expect(sources).toContain("knowledgeBase.article.emptyFeatured");
|
||||
expect(sources).toContain("knowledgeBase.article.empty");
|
||||
expect(sources).toContain("knowledgeBase.header.logoAlt");
|
||||
expect(sources).toContain("useLocaleFormatters");
|
||||
expect(sources).not.toContain('alt="Logo"');
|
||||
expect(sources).not.toContain('"No featured articles"');
|
||||
expect(sources).not.toContain('"No articles"');
|
||||
expect(sources).not.toContain("DateUtils.dateAgo");
|
||||
expect(sources).not.toContain("ui.autogenerated");
|
||||
});
|
||||
|
||||
it("keeps public category article counts locale-formatted", () => {
|
||||
const sources = [
|
||||
"apps/web/app/modules/knowledgeBase/components/categories/KbCategoriesGrid.tsx",
|
||||
"apps/web/app/modules/knowledgeBase/components/categories/KbCategoriesList.tsx",
|
||||
]
|
||||
.map((file) => fs.readFileSync(path.join(repoRoot, file), "utf8"))
|
||||
.join("\n");
|
||||
const en = JSON.parse(fs.readFileSync(path.join(repoRoot, "apps/web/public/locales/en/translations.json"), "utf8"));
|
||||
const de = JSON.parse(fs.readFileSync(path.join(repoRoot, "apps/web/public/locales/de/translations.json"), "utf8"));
|
||||
|
||||
expect(sources).toContain("useLocaleFormatters");
|
||||
expect(sources).toContain("formattedCount: formatters.number(item.articles.length)");
|
||||
expect(sources).not.toContain("{ count: item.articles.length })");
|
||||
expect(en.knowledgeBase.category.articleCount_one).toContain("{{formattedCount}}");
|
||||
expect(en.knowledgeBase.category.articleCount_other).toContain("{{formattedCount}}");
|
||||
expect(de.knowledgeBase.category.articleCount_one).toContain("{{formattedCount}}");
|
||||
expect(de.knowledgeBase.category.articleCount_other).toContain("{{formattedCount}}");
|
||||
});
|
||||
|
||||
it("keeps public route errors on public translation keys", () => {
|
||||
const sources = publicRouteFiles.map((file) => fs.readFileSync(path.join(repoRoot, file), "utf8")).join("\n");
|
||||
const en = JSON.parse(fs.readFileSync(path.join(repoRoot, "apps/web/public/locales/en/translations.json"), "utf8"));
|
||||
const de = JSON.parse(fs.readFileSync(path.join(repoRoot, "apps/web/public/locales/de/translations.json"), "utf8"));
|
||||
|
||||
expect(sources).toContain("knowledgeBase.public.errors.articleNotFound");
|
||||
expect(sources).toContain("knowledgeBase.public.errors.categoryNotFound");
|
||||
expect(sources).toContain("knowledgeBase.public.errors.invalidAction");
|
||||
expect(sources).not.toContain("knowledgeBase.admin.errors.notFound");
|
||||
expect(sources).not.toContain("knowledgeBase.admin.errors.invalidAction");
|
||||
expect(en.knowledgeBase.public.errors.articleNotFound).toBeTruthy();
|
||||
expect(en.knowledgeBase.public.errors.categoryNotFound).toBeTruthy();
|
||||
expect(en.knowledgeBase.public.errors.invalidAction).toBeTruthy();
|
||||
expect(de.knowledgeBase.public.errors.articleNotFound).toBeTruthy();
|
||||
expect(de.knowledgeBase.public.errors.categoryNotFound).toBeTruthy();
|
||||
expect(de.knowledgeBase.public.errors.invalidAction).toBeTruthy();
|
||||
});
|
||||
});
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
import { Link, useNavigate, useParams, useSearchParams } from "react-router";
|
||||
import { Fragment } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import clsx from "clsx";
|
||||
import ColorBackgroundUtils from "~/utils/shared/colors/ColorBackgroundUtils";
|
||||
import ColorTextUtils from "~/utils/shared/colors/ColorTextUtils";
|
||||
import ColorFocusUtils from "~/utils/shared/colors/ColorFocusUtils";
|
||||
import ColorRingUtils from "~/utils/shared/colors/ColorRingUtils";
|
||||
import ColorGradientUtils from "~/utils/shared/colors/ColorGradientUtils";
|
||||
import { logoLight, logoDark } from "~/assets/brand/default";
|
||||
import KnowledgeBaseUtils from "../../utils/KnowledgeBaseUtils";
|
||||
import { KnowledgeBaseDto } from "../../dtos/KnowledgeBaseDto";
|
||||
|
||||
interface Props {
|
||||
kb: KnowledgeBaseDto;
|
||||
}
|
||||
const withTitleAndDescription = true;
|
||||
export default function KbDocsHeader({ kb }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const params = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const logoAlt = t("knowledgeBase.header.logoAlt", { title: kb.title });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={clsx("bg-linear-to-r text-white", ColorGradientUtils.getFrom800To900(kb.color))}>
|
||||
<div className="mx-auto max-w-(--breakpoint-2xl) space-y-8 px-8 py-4">
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<Link to={KnowledgeBaseUtils.getKbUrl({ kb, params })} className="flex select-none items-center space-x-2">
|
||||
{kb.logo === "light" ? (
|
||||
<img className="h-8 w-auto" src={logoLight} alt={logoAlt} />
|
||||
) : kb.logo === "dark" ? (
|
||||
<img className="h-8 w-auto" src={logoDark} alt={logoAlt} />
|
||||
) : (
|
||||
<img className="h-8 w-auto" src={kb.logo} alt={logoAlt} />
|
||||
)}
|
||||
{!withTitleAndDescription && (
|
||||
<Fragment>
|
||||
<span className={clsx("hidden px-2 sm:block", ColorTextUtils.getText300(kb.color))}>|</span>
|
||||
<span className="hidden font-semibold sm:block">{kb.title}</span>
|
||||
</Fragment>
|
||||
)}
|
||||
</Link>
|
||||
<div className="flex items-center space-x-4">
|
||||
{kb.links.map((link) => {
|
||||
return (
|
||||
<a key={link.href} href={link.href} className={clsx("transition-colors duration-150 hover:text-white", ColorTextUtils.getText300(kb.color))}>
|
||||
{link.name}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
<div className="flex items-center space-x-2">
|
||||
{kb.languages.length > 1 && (
|
||||
<select
|
||||
id="lang"
|
||||
name="lang"
|
||||
defaultValue={params.lang || kb.defaultLanguage}
|
||||
className={clsx(
|
||||
"block w-full rounded-md border-0 py-1.5 pl-3 pr-10 text-white ring-1 ring-inset focus:ring-2 sm:text-sm sm:leading-6",
|
||||
ColorBackgroundUtils.getBg700(kb.color),
|
||||
ColorRingUtils.getRing800(kb.color),
|
||||
ColorFocusUtils.getRing600(kb.color)
|
||||
)}
|
||||
onChange={(e) => {
|
||||
navigate(
|
||||
`${KnowledgeBaseUtils.getKbUrl({
|
||||
kb,
|
||||
params: {
|
||||
lang: e.target.value,
|
||||
},
|
||||
})}?${searchParams.toString()}`
|
||||
);
|
||||
}}
|
||||
>
|
||||
{kb.languages.map((lang) => {
|
||||
const language = KnowledgeBaseUtils.supportedLanguages.find((f) => f.value === lang);
|
||||
if (!language) return null;
|
||||
return (
|
||||
<Fragment key={lang}>
|
||||
<option key={lang} value={lang}>
|
||||
{KnowledgeBaseUtils.getLanguageName(language.value, t)}
|
||||
</option>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* {withTitleAndDescription && (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-semibold tracking-tight sm:text-4xl">{kb.title}</h1>
|
||||
<p className={clsx("text-base", ColorTextUtils.getText300(kb.color))}>{kb.description}</p>
|
||||
</div>
|
||||
)} */}
|
||||
{/* {withSearch && <KbSearch kb={kb} autoFocus={withTitleAndDescription} />} */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
import { useLocation, useSubmit } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { KnowledgeBaseDto } from "../../dtos/KnowledgeBaseDto";
|
||||
import { KbCategoryDto } from "../../dtos/KbCategoryDto";
|
||||
import clsx from "clsx";
|
||||
import { MobileNav } from "~/components/ui/docs/DocsMobileNav";
|
||||
import { Fragment } from "react";
|
||||
import RightIcon from "~/components/ui/icons/RightIcon";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import { SideBarItem } from "~/application/sidebar/SidebarItem";
|
||||
import { KbArticleDto } from "../../dtos/KbArticleDto";
|
||||
import KbArticle from "../articles/KbArticle";
|
||||
import KnowledgeBaseUtils from "../../utils/KnowledgeBaseUtils";
|
||||
import KbDocsHeader from "./KbDocsHeader";
|
||||
import KbFeaturedArticles from "../KbFeaturedArticles";
|
||||
import KbCategories from "../categories/KbCategories";
|
||||
import { DocsSidebarNav } from "~/components/ui/docs/DocsSidebarNav";
|
||||
|
||||
export default function KbDocsLayout({
|
||||
kb,
|
||||
categories,
|
||||
featured,
|
||||
article,
|
||||
}: {
|
||||
kb: KnowledgeBaseDto;
|
||||
categories: KbCategoryDto[];
|
||||
featured?: KbArticleDto[];
|
||||
article?: {
|
||||
article: KbArticleDto;
|
||||
category: KbCategoryDto;
|
||||
userState: {
|
||||
hasThumbsUp: boolean;
|
||||
hasThumbsDown: boolean;
|
||||
};
|
||||
} | null;
|
||||
}) {
|
||||
// const outlet = useOutlet();
|
||||
const location = useLocation();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const items: SideBarItem[] = [];
|
||||
categories.forEach((category) => {
|
||||
const sections = KnowledgeBaseUtils.getCategoryArticlesBySections({ kb, category });
|
||||
items.push({
|
||||
title: category.title,
|
||||
path: category.href,
|
||||
items: [{ title: t("shared.overview"), path: category.href }],
|
||||
});
|
||||
let categoryItem = items[items.length - 1];
|
||||
sections.forEach((section) => {
|
||||
if (section.articles.length === 0) return;
|
||||
if (section.section) {
|
||||
items.push({
|
||||
title: section.section.title,
|
||||
path: "",
|
||||
items: [],
|
||||
});
|
||||
categoryItem = items[items.length - 1];
|
||||
}
|
||||
section.articles.forEach((article) => {
|
||||
categoryItem.items?.push({
|
||||
title: article.title,
|
||||
path: article.href,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const currentItem = () => {
|
||||
const findRecursive = (items: SideBarItem[]): SideBarItem | undefined => {
|
||||
for (const item of items) {
|
||||
if (item.path && item.title && item.path === location.pathname) return item;
|
||||
if (item.items) {
|
||||
const found = findRecursive(item.items);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
};
|
||||
return findRecursive(items);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<KbDocsHeader kb={kb} />
|
||||
<div className="mx-auto max-w-(--breakpoint-2xl) flex-1 items-start px-4 pt-3 sm:px-6 md:grid md:grid-cols-[220px_minmax(0,1fr)] md:gap-6 lg:grid-cols-[240px_minmax(0,1fr)] lg:gap-10">
|
||||
<nav className={clsx("not-prose border-border mb-2 flex items-center truncate border-b pb-2 md:hidden")}>
|
||||
<ol className="flex items-center space-x-2">
|
||||
<MobileNav items={items} />
|
||||
{currentItem() && (
|
||||
<Fragment>
|
||||
<RightIcon className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
<h3 className="text-muted-foreground text-base">{currentItem()?.title}</h3>
|
||||
</div>
|
||||
</Fragment>
|
||||
)}
|
||||
</ol>
|
||||
</nav>
|
||||
<aside className="fixed top-14 z-30 -ml-2 hidden h-[calc(100vh-90px)] w-full shrink-0 md:sticky md:block">
|
||||
<ScrollArea className="h-full pb-6 pr-6 lg:pb-8">
|
||||
<DocsSidebarNav items={items} />
|
||||
</ScrollArea>
|
||||
</aside>
|
||||
{article ? (
|
||||
<Article kb={kb} article={article} />
|
||||
) : (
|
||||
<div>
|
||||
<div className="mx-auto max-w-5xl px-2 py-6 sm:px-8">
|
||||
<div className="space-y-8">
|
||||
{featured && featured.length > 0 && <KbFeaturedArticles kb={kb} items={featured} />}
|
||||
<KbCategories kb={kb} items={categories} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Article({
|
||||
kb,
|
||||
article,
|
||||
}: {
|
||||
kb: KnowledgeBaseDto;
|
||||
article: {
|
||||
article: KbArticleDto;
|
||||
category: KbCategoryDto;
|
||||
userState: {
|
||||
hasThumbsUp: boolean;
|
||||
hasThumbsDown: boolean;
|
||||
};
|
||||
};
|
||||
}) {
|
||||
const submit = useSubmit();
|
||||
function onAction(name: string) {
|
||||
const form = new FormData();
|
||||
form.set("action", name);
|
||||
submit(form, {
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-2 py-2 sm:px-8 sm:py-8">
|
||||
<div className="space-y-3">
|
||||
<KbArticle
|
||||
kb={kb}
|
||||
category={article.category}
|
||||
item={article.article}
|
||||
userState={{
|
||||
hasThumbsUp: article.userState.hasThumbsUp,
|
||||
hasThumbsDown: article.userState.hasThumbsDown,
|
||||
}}
|
||||
actions={{
|
||||
onThumbsUp: () => onAction("thumbsUp"),
|
||||
onThumbsDown: () => onAction("thumbsDown"),
|
||||
}}
|
||||
withMenu={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// function getArticles({ kb, category, sectionId }: { kb: KnowledgeBaseDto; category: KbCategoryDto; sectionId: string | null }) {
|
||||
// return category.articles
|
||||
// .filter((f) => f.publishedAt !== null && (sectionId ? f.sectionId === sectionId : true))
|
||||
// .sort((a, b) => a.order - b.order)
|
||||
// .map((article) => ({
|
||||
// title: article.title,
|
||||
// path: KnowledgeBaseUtils.getArticleUrl({ kb, article, params: {} }),
|
||||
// }));
|
||||
// }
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
import TableSimple from "~/components/ui/tables/TableSimple";
|
||||
import { KnowledgeBasesTemplateDto } from "../../dtos/KnowledgeBasesTemplateDto";
|
||||
import { t as i18nT } from "i18next";
|
||||
|
||||
export default function PreviewKbsTemplate({ template }: { template: KnowledgeBasesTemplateDto }) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-medium">{i18nT("knowledgeBase.templates.preview.headings.knowledgeBases")}</h3>
|
||||
<TableSimple
|
||||
items={template.knowledgeBases}
|
||||
headers={[
|
||||
{ name: "slug", title: i18nT("knowledgeBase.templates.preview.columns.slug"), value: (i) => i.slug },
|
||||
{
|
||||
name: "title",
|
||||
title: i18nT("knowledgeBase.templates.preview.columns.title"),
|
||||
value: (i) => (
|
||||
<div className="flex-col">
|
||||
<div>{i.title}</div>
|
||||
<div className="text-muted-foreground text-xs">{i.description}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ name: "enabled", title: i18nT("knowledgeBase.templates.preview.columns.enabled"), value: (i) => i.enabled },
|
||||
]}
|
||||
/>
|
||||
|
||||
<h3 className="font-medium">{i18nT("knowledgeBase.templates.preview.headings.categories")}</h3>
|
||||
<TableSimple
|
||||
items={template.categories}
|
||||
headers={[
|
||||
{ name: "slug", title: i18nT("knowledgeBase.templates.preview.columns.slug"), value: (i) => i.slug },
|
||||
{
|
||||
name: "title",
|
||||
title: i18nT("knowledgeBase.templates.preview.columns.title"),
|
||||
value: (i) => (
|
||||
<div className="flex-col">
|
||||
<div>{i.title}</div>
|
||||
<div className="text-muted-foreground text-xs">{i.description}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "sections",
|
||||
title: i18nT("knowledgeBase.templates.preview.columns.sections"),
|
||||
value: (i) => i.sections.map((s) => s.title).join(", "),
|
||||
},
|
||||
{ name: "knowledgeBase", title: i18nT("knowledgeBase.templates.preview.columns.knowledgeBase"), value: (i) => i.knowledgeBaseSlug },
|
||||
]}
|
||||
/>
|
||||
|
||||
<h3 className="font-medium">{i18nT("knowledgeBase.templates.preview.headings.articles")}</h3>
|
||||
<TableSimple
|
||||
items={template.articles}
|
||||
headers={[
|
||||
{ name: "slug", title: i18nT("knowledgeBase.templates.preview.columns.slug"), value: (i) => i.slug },
|
||||
{
|
||||
name: "title",
|
||||
title: i18nT("knowledgeBase.templates.preview.columns.title"),
|
||||
value: (i) => (
|
||||
<div className="flex-col">
|
||||
<div>{i.title}</div>
|
||||
<div className="text-muted-foreground text-xs">{i.description}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ name: "knowledgeBase", title: i18nT("knowledgeBase.templates.preview.columns.knowledgeBase"), value: (i) => i.knowledgeBaseSlug },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { db, dbRead } from "~/utils/db.server";
|
||||
|
||||
export async function createKnowledgeBaseView({ userAnalyticsId, knowledgeBaseId }: { userAnalyticsId: string | null; knowledgeBaseId: string }) {
|
||||
if (!userAnalyticsId) {
|
||||
return;
|
||||
}
|
||||
const existing = await db.knowledgeBaseViews.findUnique({
|
||||
where: {
|
||||
knowledgeBaseId_userAnalyticsId: {
|
||||
userAnalyticsId,
|
||||
knowledgeBaseId,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!existing) {
|
||||
await db.knowledgeBaseViews.create({
|
||||
data: {
|
||||
userAnalyticsId,
|
||||
knowledgeBaseId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function createKnowledgeBaseArticleView({ userAnalyticsId, articleId }: { userAnalyticsId: string | null; articleId: string }) {
|
||||
if (!userAnalyticsId) {
|
||||
return;
|
||||
}
|
||||
const existing = await db.knowledgeBaseArticleViews.findUnique({
|
||||
where: {
|
||||
knowledgeBaseArticleId_userAnalyticsId: {
|
||||
userAnalyticsId,
|
||||
knowledgeBaseArticleId: articleId,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!existing) {
|
||||
await db.knowledgeBaseArticleViews.create({
|
||||
data: {
|
||||
userAnalyticsId,
|
||||
knowledgeBaseArticleId: articleId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function voteArticle({ userAnalyticsId, articleId, type }: { userAnalyticsId: string | null; articleId: string; type: "up" | "down" }) {
|
||||
if (!userAnalyticsId) {
|
||||
return;
|
||||
}
|
||||
if (type === "up") {
|
||||
await db.knowledgeBaseArticleDownvotes.deleteMany({
|
||||
where: {
|
||||
userAnalyticsId,
|
||||
knowledgeBaseArticleId: articleId,
|
||||
},
|
||||
});
|
||||
const existing = await db.knowledgeBaseArticleUpvotes.findUnique({
|
||||
where: {
|
||||
knowledgeBaseArticleId_userAnalyticsId: {
|
||||
userAnalyticsId,
|
||||
knowledgeBaseArticleId: articleId,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!existing) {
|
||||
await db.knowledgeBaseArticleUpvotes.create({
|
||||
data: {
|
||||
userAnalyticsId,
|
||||
knowledgeBaseArticleId: articleId,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await db.knowledgeBaseArticleUpvotes.delete({
|
||||
where: {
|
||||
knowledgeBaseArticleId_userAnalyticsId: {
|
||||
userAnalyticsId,
|
||||
knowledgeBaseArticleId: articleId,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await db.knowledgeBaseArticleUpvotes.deleteMany({
|
||||
where: {
|
||||
userAnalyticsId,
|
||||
knowledgeBaseArticleId: articleId,
|
||||
},
|
||||
});
|
||||
const existing = await db.knowledgeBaseArticleDownvotes.findUnique({
|
||||
where: {
|
||||
knowledgeBaseArticleId_userAnalyticsId: {
|
||||
userAnalyticsId,
|
||||
knowledgeBaseArticleId: articleId,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!existing) {
|
||||
await db.knowledgeBaseArticleDownvotes.create({
|
||||
data: {
|
||||
userAnalyticsId,
|
||||
knowledgeBaseArticleId: articleId,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await db.knowledgeBaseArticleDownvotes.delete({
|
||||
where: {
|
||||
knowledgeBaseArticleId_userAnalyticsId: {
|
||||
userAnalyticsId,
|
||||
knowledgeBaseArticleId: articleId,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getArticleStateByUserAnalyticsId({ userAnalyticsId, articleId }: { userAnalyticsId: string | null; articleId: string }) {
|
||||
if (!userAnalyticsId) {
|
||||
return {
|
||||
hasThumbsUp: false,
|
||||
hasThumbsDown: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
hasThumbsUp: !!(await db.knowledgeBaseArticleUpvotes.findUnique({
|
||||
where: {
|
||||
knowledgeBaseArticleId_userAnalyticsId: {
|
||||
userAnalyticsId,
|
||||
knowledgeBaseArticleId: articleId,
|
||||
},
|
||||
},
|
||||
})),
|
||||
hasThumbsDown: !!(await db.knowledgeBaseArticleDownvotes.findUnique({
|
||||
where: {
|
||||
knowledgeBaseArticleId_userAnalyticsId: {
|
||||
userAnalyticsId,
|
||||
knowledgeBaseArticleId: articleId,
|
||||
},
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function countAllKbsViews() {
|
||||
// Dashboard count — read-only, replica-safe.
|
||||
return await dbRead.knowledgeBaseViews.count();
|
||||
}
|
||||
|
||||
export async function countAllKbsArticleViews() {
|
||||
// Dashboard count — read-only, replica-safe.
|
||||
return await dbRead.knowledgeBaseArticleViews.count();
|
||||
}
|
||||
|
||||
export async function countAllKbsArticleUpvotes() {
|
||||
// Dashboard count — read-only, replica-safe.
|
||||
return await dbRead.knowledgeBaseArticleUpvotes.count();
|
||||
}
|
||||
|
||||
export async function countAllKbsArticleDownvotes() {
|
||||
// Dashboard count — read-only, replica-safe.
|
||||
return await dbRead.knowledgeBaseArticleDownvotes.count();
|
||||
}
|
||||
@@ -0,0 +1,634 @@
|
||||
import { KnowledgeBaseArticle, KnowledgeBaseRelatedArticle, Prisma } from "@toolctrl/db/prisma-browser";
|
||||
import { PaginationDto } from "~/application/dtos/data/PaginationDto";
|
||||
import { cachified, clearCacheKey } from "~/utils/cache.server";
|
||||
import { db, dbRead } from "~/utils/db.server";
|
||||
|
||||
export type KnowledgeBaseArticleWithDetails = KnowledgeBaseArticle & {
|
||||
knowledgeBase: { id: string; slug: string; title: string };
|
||||
category: { slug: string; title: string } | null;
|
||||
section: { order: number; title: string } | null;
|
||||
relatedArticles: (KnowledgeBaseRelatedArticle & {
|
||||
relatedArticle: { id: string; order: number; title: string; slug: string };
|
||||
})[];
|
||||
createdByUser: {
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
avatar: string | null;
|
||||
} | null;
|
||||
_count: {
|
||||
views: number;
|
||||
upvotes: number;
|
||||
downvotes: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type KnowledgeBaseArticleSimple = {
|
||||
id: string;
|
||||
knowledgeBaseId: string;
|
||||
title: string;
|
||||
language: string;
|
||||
category: { id: string; slug: string; title: string } | null;
|
||||
section: { id: string; order: number; title: string } | null;
|
||||
};
|
||||
|
||||
const include = {
|
||||
knowledgeBase: {
|
||||
select: { slug: true, title: true, id: true },
|
||||
},
|
||||
category: {
|
||||
select: { slug: true, title: true },
|
||||
},
|
||||
section: {
|
||||
select: { order: true, title: true },
|
||||
},
|
||||
relatedArticles: {
|
||||
include: {
|
||||
relatedArticle: {
|
||||
select: { id: true, order: true, title: true, slug: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
createdByUser: {
|
||||
select: { email: true, firstName: true, lastName: true, avatar: true },
|
||||
},
|
||||
_count: {
|
||||
select: { views: true, upvotes: true, downvotes: true },
|
||||
},
|
||||
};
|
||||
|
||||
export async function getAllKnowledgeBaseArticles({
|
||||
knowledgeBaseSlug,
|
||||
categoryId,
|
||||
language,
|
||||
}: {
|
||||
knowledgeBaseSlug: string;
|
||||
categoryId?: string;
|
||||
language: string | undefined;
|
||||
}): Promise<KnowledgeBaseArticleWithDetails[]> {
|
||||
// Public KB listing — eventually-consistent read is fine.
|
||||
return await dbRead.knowledgeBaseArticle.findMany({
|
||||
where: {
|
||||
knowledgeBase: { slug: knowledgeBaseSlug },
|
||||
categoryId,
|
||||
language,
|
||||
},
|
||||
include,
|
||||
orderBy: [
|
||||
{
|
||||
category: { order: "asc" },
|
||||
},
|
||||
{
|
||||
section: { order: "asc" },
|
||||
},
|
||||
{
|
||||
order: "asc",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export async function getKnowledgeBaseArticlesForTemplateExport({
|
||||
knowledgeBaseIds,
|
||||
}: {
|
||||
knowledgeBaseIds: string[];
|
||||
}): Promise<KnowledgeBaseArticleWithDetails[]> {
|
||||
if (knowledgeBaseIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return await dbRead.knowledgeBaseArticle.findMany({
|
||||
where: {
|
||||
knowledgeBaseId: { in: knowledgeBaseIds },
|
||||
},
|
||||
include,
|
||||
orderBy: [
|
||||
{
|
||||
knowledgeBaseId: "asc",
|
||||
},
|
||||
{
|
||||
category: { order: "asc" },
|
||||
},
|
||||
{
|
||||
section: { order: "asc" },
|
||||
},
|
||||
{
|
||||
order: "asc",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAllKnowledgeBaseArticlesSimple(): Promise<KnowledgeBaseArticleSimple[]> {
|
||||
// Admin sidebar enumeration — read-only, replica-safe.
|
||||
return await dbRead.knowledgeBaseArticle.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
knowledgeBaseId: true,
|
||||
title: true,
|
||||
language: true,
|
||||
category: { select: { id: true, slug: true, title: true } },
|
||||
section: { select: { id: true, order: true, title: true } },
|
||||
},
|
||||
orderBy: [
|
||||
{
|
||||
knowledgeBaseId: "asc",
|
||||
},
|
||||
{
|
||||
category: { order: "asc" },
|
||||
},
|
||||
{
|
||||
section: { order: "asc" },
|
||||
},
|
||||
{
|
||||
order: "asc",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export async function getKnowledgeBaseArticlesSimple({
|
||||
knowledgeBaseId,
|
||||
language,
|
||||
}: {
|
||||
knowledgeBaseId: string;
|
||||
language: string;
|
||||
}): Promise<KnowledgeBaseArticleSimple[]> {
|
||||
// Per-KB simple listing — read-only.
|
||||
return await dbRead.knowledgeBaseArticle.findMany({
|
||||
where: { knowledgeBaseId, language },
|
||||
select: {
|
||||
id: true,
|
||||
knowledgeBaseId: true,
|
||||
title: true,
|
||||
language: true,
|
||||
category: { select: { id: true, slug: true, title: true } },
|
||||
section: { select: { id: true, order: true, title: true } },
|
||||
},
|
||||
orderBy: [{ category: { order: "asc" } }, { section: { order: "asc" } }, { order: "asc" }],
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAllKnowledgeBaseArticlesWithPagination({
|
||||
knowledgeBaseSlug,
|
||||
language,
|
||||
pagination,
|
||||
filters,
|
||||
}: {
|
||||
knowledgeBaseSlug: string | undefined;
|
||||
language: string | undefined;
|
||||
pagination: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
};
|
||||
filters: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
categoryId?: string | null;
|
||||
content?: string;
|
||||
};
|
||||
}): Promise<{
|
||||
items: KnowledgeBaseArticleWithDetails[];
|
||||
pagination: PaginationDto;
|
||||
}> {
|
||||
const where: Prisma.KnowledgeBaseArticleWhereInput = {
|
||||
knowledgeBase: { slug: knowledgeBaseSlug },
|
||||
language,
|
||||
};
|
||||
if (filters.title !== undefined) {
|
||||
where.title = { contains: filters.title, mode: "insensitive" };
|
||||
}
|
||||
if (filters.description !== undefined) {
|
||||
where.description = { contains: filters.description, mode: "insensitive" };
|
||||
}
|
||||
if (filters.categoryId !== undefined) {
|
||||
where.categoryId = filters.categoryId;
|
||||
}
|
||||
if (filters.content !== undefined) {
|
||||
where.contentPublishedAsText = { contains: filters.content, mode: "insensitive" };
|
||||
}
|
||||
// Paginated listing for admin grid — read-only, replica-safe.
|
||||
const items = await dbRead.knowledgeBaseArticle.findMany({
|
||||
take: pagination.pageSize,
|
||||
skip: (pagination.page - 1) * pagination.pageSize,
|
||||
where,
|
||||
include,
|
||||
orderBy: [{ updatedAt: "desc" }, { createdAt: "desc" }],
|
||||
});
|
||||
const totalItems = await dbRead.knowledgeBaseArticle.count({
|
||||
where,
|
||||
});
|
||||
return {
|
||||
items,
|
||||
pagination: {
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
totalItems,
|
||||
totalPages: Math.ceil(totalItems / pagination.pageSize),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function findKnowledgeBaseArticles({
|
||||
knowledgeBaseSlug,
|
||||
language,
|
||||
query,
|
||||
}: {
|
||||
knowledgeBaseSlug: string;
|
||||
language: string | undefined;
|
||||
query: string;
|
||||
}): Promise<KnowledgeBaseArticleWithDetails[]> {
|
||||
// Public KB search — read-only, replica-safe.
|
||||
return await dbRead.knowledgeBaseArticle.findMany({
|
||||
where: {
|
||||
knowledgeBase: { slug: knowledgeBaseSlug },
|
||||
language,
|
||||
publishedAt: { not: null },
|
||||
OR: [
|
||||
{ title: { contains: query, mode: "insensitive" } },
|
||||
{ description: { contains: query, mode: "insensitive" } },
|
||||
{
|
||||
contentPublishedAsText: { contains: query, mode: "insensitive" },
|
||||
},
|
||||
],
|
||||
},
|
||||
include,
|
||||
orderBy: [
|
||||
{
|
||||
category: { order: "asc" },
|
||||
},
|
||||
{
|
||||
section: { order: "asc" },
|
||||
},
|
||||
{
|
||||
order: "asc",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export async function findKnowledgeBaseArticlesByIds({
|
||||
knowledgeBaseSlug,
|
||||
language,
|
||||
ids,
|
||||
}: {
|
||||
knowledgeBaseSlug: string;
|
||||
language: string | undefined;
|
||||
ids: string[];
|
||||
}): Promise<KnowledgeBaseArticleWithDetails[]> {
|
||||
if (ids.length === 0) return [];
|
||||
// Public KB search hydration — read-only, replica-safe.
|
||||
return await dbRead.knowledgeBaseArticle.findMany({
|
||||
where: {
|
||||
id: { in: ids },
|
||||
knowledgeBase: { slug: knowledgeBaseSlug },
|
||||
language,
|
||||
publishedAt: { not: null },
|
||||
},
|
||||
include,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getFeaturedKnowledgeBaseArticles({
|
||||
knowledgeBaseId,
|
||||
language,
|
||||
}: {
|
||||
knowledgeBaseId: string;
|
||||
language: string;
|
||||
}): Promise<KnowledgeBaseArticleWithDetails[]> {
|
||||
// Featured-article enumeration for public KB landing — read-only.
|
||||
return await dbRead.knowledgeBaseArticle.findMany({
|
||||
where: {
|
||||
knowledgeBaseId,
|
||||
language,
|
||||
featuredOrder: { not: null },
|
||||
publishedAt: { not: null },
|
||||
},
|
||||
include,
|
||||
orderBy: [{ featuredOrder: "asc" }],
|
||||
});
|
||||
}
|
||||
|
||||
export async function getKbArticleById(id: string): Promise<KnowledgeBaseArticleWithDetails | null> {
|
||||
// Read-only lookup; admin edit views read this then save via a separate
|
||||
// request through the write client.
|
||||
return await dbRead.knowledgeBaseArticle.findUnique({
|
||||
where: { id },
|
||||
include,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true only when the article exists AND its parent knowledge base is
|
||||
* owned by the given tenant. Used to scope resource access (e.g. realtime-collab
|
||||
* token issuance) so a token can never be minted for a global or other-tenant
|
||||
* article id via a tenant context.
|
||||
*/
|
||||
export async function knowledgeBaseArticleBelongsToTenant(articleId: string, tenantId: string): Promise<boolean> {
|
||||
const article = await dbRead.knowledgeBaseArticle.findUnique({
|
||||
where: { id: articleId },
|
||||
select: { knowledgeBase: { select: { tenantId: true } } },
|
||||
});
|
||||
return article?.knowledgeBase?.tenantId === tenantId;
|
||||
}
|
||||
|
||||
export async function getKbArticleByIdInKnowledgeBase({
|
||||
id,
|
||||
knowledgeBaseId,
|
||||
language,
|
||||
}: {
|
||||
id: string;
|
||||
knowledgeBaseId: string;
|
||||
language: string;
|
||||
}): Promise<KnowledgeBaseArticleWithDetails | null> {
|
||||
// Read-only lookup, replica-safe.
|
||||
return await dbRead.knowledgeBaseArticle.findFirst({
|
||||
where: { id, knowledgeBaseId, language },
|
||||
include,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getKbArticleBySlug({
|
||||
knowledgeBaseId,
|
||||
slug,
|
||||
language,
|
||||
}: {
|
||||
knowledgeBaseId: string;
|
||||
slug: string;
|
||||
language: string;
|
||||
}): Promise<KnowledgeBaseArticleWithDetails | null> {
|
||||
return await cachified({
|
||||
key: `kb-article:${knowledgeBaseId}:${slug}:${language}`,
|
||||
ttl: 60 * 60 * 24,
|
||||
getFreshValue: () =>
|
||||
// Public KB article fetch (cached for 24h) — replica-safe; lag is
|
||||
// well under the cache TTL.
|
||||
dbRead.knowledgeBaseArticle
|
||||
.findFirstOrThrow({
|
||||
where: {
|
||||
knowledgeBaseId,
|
||||
slug,
|
||||
language,
|
||||
},
|
||||
include: {
|
||||
...include,
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
return null;
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createKnowledgeBaseArticle(data: {
|
||||
knowledgeBaseId: string;
|
||||
categoryId: string | null;
|
||||
sectionId: string | null;
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
order: number;
|
||||
contentDraft: string;
|
||||
contentPublished: string;
|
||||
contentPublishedAsText: string;
|
||||
contentType: string;
|
||||
language: string;
|
||||
featuredOrder: number | null;
|
||||
createdByUserId: string | null;
|
||||
seoImage: string;
|
||||
publishedAt: Date | null;
|
||||
}) {
|
||||
const article = await db.knowledgeBaseArticle.create({
|
||||
data: {
|
||||
knowledgeBaseId: data.knowledgeBaseId,
|
||||
categoryId: data.categoryId,
|
||||
sectionId: data.sectionId,
|
||||
slug: data.slug,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
order: data.order,
|
||||
contentDraft: data.contentDraft,
|
||||
contentPublished: data.contentPublished,
|
||||
contentPublishedAsText: data.contentPublishedAsText,
|
||||
contentType: data.contentType,
|
||||
language: data.language,
|
||||
featuredOrder: data.featuredOrder,
|
||||
createdByUserId: data.createdByUserId,
|
||||
seoImage: data.seoImage,
|
||||
publishedAt: data.publishedAt,
|
||||
},
|
||||
});
|
||||
return article;
|
||||
}
|
||||
|
||||
export async function updateKnowledgeBaseArticle(
|
||||
id: string,
|
||||
data: {
|
||||
categoryId?: string | null;
|
||||
sectionId?: string | null;
|
||||
slug?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
order?: number;
|
||||
contentDraft?: string;
|
||||
contentPublished?: string;
|
||||
contentPublishedAsText?: string;
|
||||
contentType?: string;
|
||||
language?: string;
|
||||
featuredOrder?: number | null;
|
||||
createdByUserId?: string | null;
|
||||
seoImage?: string;
|
||||
publishedAt?: Date | null;
|
||||
relatedArticles?: string[];
|
||||
}
|
||||
) {
|
||||
const update: Prisma.KnowledgeBaseArticleUncheckedUpdateInput = {
|
||||
categoryId: data.categoryId,
|
||||
sectionId: data.sectionId,
|
||||
slug: data.slug,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
order: data.order,
|
||||
contentDraft: data.contentDraft,
|
||||
contentPublished: data.contentPublished,
|
||||
contentPublishedAsText: data.contentPublishedAsText,
|
||||
contentType: data.contentType,
|
||||
language: data.language,
|
||||
featuredOrder: data.featuredOrder,
|
||||
createdByUserId: data.createdByUserId,
|
||||
seoImage: data.seoImage,
|
||||
publishedAt: data.publishedAt,
|
||||
};
|
||||
const updated = await db.knowledgeBaseArticle
|
||||
.update({
|
||||
where: { id },
|
||||
data: update,
|
||||
})
|
||||
.then((item) => {
|
||||
clearCacheKey(`kb-article:${item.knowledgeBaseId}:${item.slug}:${item.language}`);
|
||||
return item;
|
||||
});
|
||||
if (data.relatedArticles) {
|
||||
await db.knowledgeBaseRelatedArticle.deleteMany({
|
||||
where: {
|
||||
articleId: id,
|
||||
},
|
||||
});
|
||||
await db.knowledgeBaseRelatedArticle.createMany({
|
||||
data: data.relatedArticles.map((f) => {
|
||||
return {
|
||||
articleId: id,
|
||||
relatedArticleId: f,
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateKnowledgeBaseArticleInKnowledgeBase(
|
||||
{
|
||||
id,
|
||||
knowledgeBaseId,
|
||||
language,
|
||||
}: {
|
||||
id: string;
|
||||
knowledgeBaseId: string;
|
||||
language: string;
|
||||
},
|
||||
data: {
|
||||
categoryId?: string | null;
|
||||
sectionId?: string | null;
|
||||
slug?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
order?: number;
|
||||
contentDraft?: string;
|
||||
contentPublished?: string;
|
||||
contentPublishedAsText?: string;
|
||||
contentType?: string;
|
||||
language?: string;
|
||||
featuredOrder?: number | null;
|
||||
createdByUserId?: string | null;
|
||||
seoImage?: string;
|
||||
publishedAt?: Date | null;
|
||||
relatedArticles?: string[];
|
||||
}
|
||||
) {
|
||||
const update: Prisma.KnowledgeBaseArticleUncheckedUpdateInput = {
|
||||
categoryId: data.categoryId,
|
||||
sectionId: data.sectionId,
|
||||
slug: data.slug,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
order: data.order,
|
||||
contentDraft: data.contentDraft,
|
||||
contentPublished: data.contentPublished,
|
||||
contentPublishedAsText: data.contentPublishedAsText,
|
||||
contentType: data.contentType,
|
||||
language: data.language,
|
||||
featuredOrder: data.featuredOrder,
|
||||
createdByUserId: data.createdByUserId,
|
||||
seoImage: data.seoImage,
|
||||
publishedAt: data.publishedAt,
|
||||
};
|
||||
const result = await db.knowledgeBaseArticle.updateMany({
|
||||
where: { id, knowledgeBaseId, language },
|
||||
data: update,
|
||||
});
|
||||
if (result.count !== 1) {
|
||||
return result;
|
||||
}
|
||||
const item = await db.knowledgeBaseArticle.findUnique({ where: { id }, select: { knowledgeBaseId: true, slug: true, language: true } });
|
||||
if (item) {
|
||||
clearCacheKey(`kb-article:${item.knowledgeBaseId}:${item.slug}:${item.language}`);
|
||||
}
|
||||
if (data.relatedArticles) {
|
||||
await db.knowledgeBaseRelatedArticle.deleteMany({
|
||||
where: {
|
||||
articleId: id,
|
||||
article: { knowledgeBaseId, language },
|
||||
},
|
||||
});
|
||||
await db.knowledgeBaseRelatedArticle.createMany({
|
||||
data: data.relatedArticles.map((f) => {
|
||||
return {
|
||||
articleId: id,
|
||||
relatedArticleId: f,
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function updateKnowledgeBaseArticleOrderInCategory({ id, categoryId, order }: { id: string; categoryId: string; order: number }) {
|
||||
return await db.knowledgeBaseArticle.updateMany({
|
||||
where: { id, categoryId },
|
||||
data: { order },
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateKnowledgeBaseArticleOrderInSection({ id, sectionId, order }: { id: string; sectionId: string; order: number }) {
|
||||
return await db.knowledgeBaseArticle.updateMany({
|
||||
where: { id, sectionId },
|
||||
data: { order },
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateKnowledgeBaseArticleOrderInKnowledgeBase({
|
||||
id,
|
||||
knowledgeBaseId,
|
||||
language,
|
||||
order,
|
||||
}: {
|
||||
id: string;
|
||||
knowledgeBaseId: string;
|
||||
language: string;
|
||||
order: number;
|
||||
}) {
|
||||
return await db.knowledgeBaseArticle.updateMany({
|
||||
where: { id, knowledgeBaseId, language },
|
||||
data: { order },
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteKnowledgeBaseArticle(id: string) {
|
||||
const result = await db.knowledgeBaseArticle
|
||||
.delete({
|
||||
where: { id },
|
||||
})
|
||||
.then((item) => {
|
||||
clearCacheKey(`kb-article:${item.knowledgeBaseId}:${item.slug}:${item.language}`);
|
||||
return item;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function deleteKnowledgeBaseArticleInKnowledgeBase({
|
||||
id,
|
||||
knowledgeBaseId,
|
||||
language,
|
||||
}: {
|
||||
id: string;
|
||||
knowledgeBaseId: string;
|
||||
language: string;
|
||||
}) {
|
||||
const item = await db.knowledgeBaseArticle.findFirst({
|
||||
where: { id, knowledgeBaseId, language },
|
||||
select: { id: true, slug: true },
|
||||
});
|
||||
if (!item) {
|
||||
return { count: 0 };
|
||||
}
|
||||
const result = await db.knowledgeBaseArticle.deleteMany({
|
||||
where: { id, knowledgeBaseId, language },
|
||||
});
|
||||
if (result.count === 1) {
|
||||
clearCacheKey(`kb-article:${knowledgeBaseId}:${item.slug}:${language}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function countKnowledgeBaseArticles() {
|
||||
// Dashboard count — read-only, replica-safe.
|
||||
return await dbRead.knowledgeBaseArticle.count();
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { db, dbRead } from "~/utils/db.server";
|
||||
import { KnowledgeBaseCategoryWithDetails } from "../helpers/KbCategoryModelHelper";
|
||||
import { Prisma } from "@toolctrl/db/prisma-browser";
|
||||
|
||||
export type KnowledgeBaseCategorySimple = {
|
||||
id: string;
|
||||
knowledgeBaseId: string;
|
||||
title: string;
|
||||
language: string;
|
||||
sections: { id: string; order: number; title: string }[];
|
||||
};
|
||||
|
||||
const include = {
|
||||
articles: {
|
||||
select: { id: true, order: true, title: true, description: true, slug: true, language: true, sectionId: true, publishedAt: true },
|
||||
},
|
||||
sections: {
|
||||
select: { id: true, order: true, title: true, description: true },
|
||||
},
|
||||
};
|
||||
|
||||
export async function getAllKnowledgeBaseCategories({
|
||||
knowledgeBaseSlug,
|
||||
language,
|
||||
}: {
|
||||
knowledgeBaseSlug: string | undefined;
|
||||
language: string | undefined;
|
||||
}): Promise<KnowledgeBaseCategoryWithDetails[]> {
|
||||
// Public KB category list — read-only, replica-safe.
|
||||
return await dbRead.knowledgeBaseCategory.findMany({
|
||||
where: {
|
||||
knowledgeBase: { slug: knowledgeBaseSlug },
|
||||
language,
|
||||
},
|
||||
include,
|
||||
orderBy: { order: "asc" },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getKnowledgeBaseCategoriesForTemplateExport({
|
||||
knowledgeBaseIds,
|
||||
}: {
|
||||
knowledgeBaseIds: string[];
|
||||
}): Promise<KnowledgeBaseCategoryWithDetails[]> {
|
||||
if (knowledgeBaseIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return await dbRead.knowledgeBaseCategory.findMany({
|
||||
where: {
|
||||
knowledgeBaseId: { in: knowledgeBaseIds },
|
||||
},
|
||||
include,
|
||||
orderBy: [{ knowledgeBaseId: "asc" }, { order: "asc" }],
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAllKnowledgeBaseCategoriesSimple(): Promise<KnowledgeBaseCategorySimple[]> {
|
||||
// Admin sidebar enumeration — read-only.
|
||||
return await dbRead.knowledgeBaseCategory.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
knowledgeBaseId: true,
|
||||
title: true,
|
||||
language: true,
|
||||
sections: { select: { id: true, order: true, title: true } },
|
||||
},
|
||||
orderBy: [{ knowledgeBaseId: "asc" }, { order: "asc" }],
|
||||
});
|
||||
}
|
||||
|
||||
export async function getKbCategoryById(id: string): Promise<KnowledgeBaseCategoryWithDetails | null> {
|
||||
// Read-only lookup.
|
||||
return await dbRead.knowledgeBaseCategory.findUnique({
|
||||
where: { id },
|
||||
include,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getKbCategoryByIdInKnowledgeBase({
|
||||
id,
|
||||
knowledgeBaseId,
|
||||
language,
|
||||
}: {
|
||||
id: string;
|
||||
knowledgeBaseId: string;
|
||||
language: string;
|
||||
}): Promise<KnowledgeBaseCategoryWithDetails | null> {
|
||||
// Read-only lookup.
|
||||
return await dbRead.knowledgeBaseCategory.findFirst({
|
||||
where: { id, knowledgeBaseId, language },
|
||||
include,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getKbCategoryBySlug({
|
||||
knowledgeBaseId,
|
||||
slug,
|
||||
language,
|
||||
}: {
|
||||
knowledgeBaseId: string;
|
||||
slug: string;
|
||||
language: string | undefined;
|
||||
}): Promise<KnowledgeBaseCategoryWithDetails | null> {
|
||||
const where: Prisma.KnowledgeBaseCategoryWhereInput = {
|
||||
knowledgeBaseId,
|
||||
slug,
|
||||
language,
|
||||
};
|
||||
// Public route lookup, read-only.
|
||||
return await dbRead.knowledgeBaseCategory.findFirst({
|
||||
where,
|
||||
include,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createKnowledgeBaseCategory(data: {
|
||||
knowledgeBaseId: string;
|
||||
slug: string;
|
||||
order: number;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
language: string;
|
||||
seoImage: string;
|
||||
}): Promise<KnowledgeBaseCategoryWithDetails> {
|
||||
return await db.knowledgeBaseCategory.create({
|
||||
data: {
|
||||
knowledgeBaseId: data.knowledgeBaseId,
|
||||
slug: data.slug,
|
||||
order: data.order,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
icon: data.icon,
|
||||
language: data.language,
|
||||
seoImage: data.seoImage,
|
||||
},
|
||||
include,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateKnowledgeBaseCategory(
|
||||
id: string,
|
||||
data: {
|
||||
slug?: string;
|
||||
title?: string;
|
||||
order?: number;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
language?: string;
|
||||
seoImage?: string;
|
||||
}
|
||||
) {
|
||||
return await db.knowledgeBaseCategory.update({
|
||||
where: { id },
|
||||
data: {
|
||||
slug: data.slug,
|
||||
order: data.order,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
icon: data.icon,
|
||||
language: data.language,
|
||||
seoImage: data.seoImage,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateKnowledgeBaseCategoryInKnowledgeBase(
|
||||
{
|
||||
id,
|
||||
knowledgeBaseId,
|
||||
language,
|
||||
}: {
|
||||
id: string;
|
||||
knowledgeBaseId: string;
|
||||
language: string;
|
||||
},
|
||||
data: {
|
||||
slug?: string;
|
||||
title?: string;
|
||||
order?: number;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
language?: string;
|
||||
seoImage?: string;
|
||||
}
|
||||
) {
|
||||
return await db.knowledgeBaseCategory.updateMany({
|
||||
where: { id, knowledgeBaseId, language },
|
||||
data: {
|
||||
slug: data.slug,
|
||||
order: data.order,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
icon: data.icon,
|
||||
language: data.language,
|
||||
seoImage: data.seoImage,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateKnowledgeBaseCategoryOrderInKnowledgeBase({
|
||||
id,
|
||||
knowledgeBaseId,
|
||||
language,
|
||||
order,
|
||||
}: {
|
||||
id: string;
|
||||
knowledgeBaseId: string;
|
||||
language: string;
|
||||
order: number;
|
||||
}) {
|
||||
return await db.knowledgeBaseCategory.updateMany({
|
||||
where: { id, knowledgeBaseId, language },
|
||||
data: { order },
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteKnowledgeBaseCategory(id: string) {
|
||||
return await db.knowledgeBaseCategory.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteKnowledgeBaseCategoryInKnowledgeBase({
|
||||
id,
|
||||
knowledgeBaseId,
|
||||
language,
|
||||
}: {
|
||||
id: string;
|
||||
knowledgeBaseId: string;
|
||||
language: string;
|
||||
}) {
|
||||
return await db.knowledgeBaseCategory.deleteMany({
|
||||
where: { id, knowledgeBaseId, language },
|
||||
});
|
||||
}
|
||||
|
||||
export async function countKnowledgeBaseCategories() {
|
||||
// Dashboard count — read-only.
|
||||
return await dbRead.knowledgeBaseCategory.count();
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { KnowledgeBaseCategorySection } from "@toolctrl/db/prisma-browser";
|
||||
import { db, dbRead } from "~/utils/db.server";
|
||||
|
||||
export type KnowledgeBaseCategorySectionWithDetails = KnowledgeBaseCategorySection & {
|
||||
articles: {
|
||||
id: string;
|
||||
order: number;
|
||||
title: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
export async function getAllKnowledgeBaseCategorySections(): Promise<KnowledgeBaseCategorySectionWithDetails[]> {
|
||||
// Read-only listing used by public KB rendering — replica is fine.
|
||||
return await dbRead.knowledgeBaseCategorySection.findMany({
|
||||
include: {
|
||||
articles: { select: { id: true, order: true, title: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getKbCategorySectionById(id: string): Promise<KnowledgeBaseCategorySectionWithDetails | null> {
|
||||
// Read-only lookup; called from both admin edit views and public rendering.
|
||||
return await dbRead.knowledgeBaseCategorySection.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
articles: { select: { id: true, order: true, title: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getKbCategorySectionByIdInCategory({
|
||||
id,
|
||||
categoryId,
|
||||
}: {
|
||||
id: string;
|
||||
categoryId: string;
|
||||
}): Promise<KnowledgeBaseCategorySectionWithDetails | null> {
|
||||
// Read-only lookup, replica-safe.
|
||||
return await dbRead.knowledgeBaseCategorySection.findFirst({
|
||||
where: { id, categoryId },
|
||||
include: {
|
||||
articles: { select: { id: true, order: true, title: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function createKnowledgeBaseCategorySection(data: { categoryId: string; order: number; title: string; description: string }) {
|
||||
return await db.knowledgeBaseCategorySection.create({
|
||||
data: {
|
||||
categoryId: data.categoryId,
|
||||
order: data.order,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateKnowledgeBaseCategorySection(
|
||||
id: string,
|
||||
data: {
|
||||
order?: number;
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
) {
|
||||
return await db.knowledgeBaseCategorySection.update({
|
||||
where: { id },
|
||||
data: {
|
||||
order: data.order,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateKnowledgeBaseCategorySectionInCategory(
|
||||
{ id, categoryId }: { id: string; categoryId: string },
|
||||
data: {
|
||||
order?: number;
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
) {
|
||||
return await db.knowledgeBaseCategorySection.updateMany({
|
||||
where: { id, categoryId },
|
||||
data: {
|
||||
order: data.order,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateKnowledgeBaseCategorySectionOrderInKnowledgeBase({
|
||||
id,
|
||||
knowledgeBaseId,
|
||||
language,
|
||||
order,
|
||||
}: {
|
||||
id: string;
|
||||
knowledgeBaseId: string;
|
||||
language: string;
|
||||
order: number;
|
||||
}) {
|
||||
return await db.knowledgeBaseCategorySection.updateMany({
|
||||
where: { id, category: { knowledgeBaseId, language } },
|
||||
data: { order },
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteKnowledgeBaseCategorySection(id: string) {
|
||||
return await db.knowledgeBaseCategorySection.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteKnowledgeBaseCategorySectionInCategory({ id, categoryId }: { id: string; categoryId: string }) {
|
||||
return await db.knowledgeBaseCategorySection.deleteMany({
|
||||
where: { id, categoryId },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { KnowledgeBase } from "@toolctrl/db/prisma-browser";
|
||||
import { clearCacheKey } from "~/utils/cache.server";
|
||||
import { db, dbRead } from "~/utils/db.server";
|
||||
|
||||
export type KnowledgeBaseWithDetails = KnowledgeBase & {
|
||||
_count: {
|
||||
categories: number;
|
||||
articles: number;
|
||||
views: number;
|
||||
};
|
||||
};
|
||||
|
||||
const include = {
|
||||
_count: {
|
||||
select: { categories: true, articles: true, views: true },
|
||||
},
|
||||
};
|
||||
|
||||
function knowledgeBaseOwnerKey(tenantId?: string | null) {
|
||||
return tenantId ?? "global";
|
||||
}
|
||||
|
||||
export async function getAllKnowledgeBases({ enabled, tenantId }: { enabled?: boolean; tenantId?: string | null } = {}): Promise<KnowledgeBaseWithDetails[]> {
|
||||
// Listing for KB management UI and public KB discovery — read-only.
|
||||
return await dbRead.knowledgeBase.findMany({
|
||||
where: { enabled, ownerKey: knowledgeBaseOwnerKey(tenantId) },
|
||||
include,
|
||||
orderBy: {
|
||||
createdAt: "asc",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAllKnowledgeBaseNames({ tenantId }: { tenantId?: string | null } = {}): Promise<{ id: string; title: string }[]> {
|
||||
// Simple enumeration — read-only.
|
||||
return await dbRead.knowledgeBase.findMany({
|
||||
where: { ownerKey: knowledgeBaseOwnerKey(tenantId) },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "asc",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getKnowledgeBaseById(id: string): Promise<KnowledgeBaseWithDetails | null> {
|
||||
// Read-only lookup.
|
||||
return await dbRead.knowledgeBase.findUnique({
|
||||
where: { id },
|
||||
include,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getKnowledgeBaseBySlug(slug: string, { tenantId }: { tenantId?: string | null } = {}): Promise<KnowledgeBaseWithDetails | null> {
|
||||
// Public route lookup, read-only.
|
||||
return await dbRead.knowledgeBase.findUnique({
|
||||
where: { ownerKey_slug: { ownerKey: knowledgeBaseOwnerKey(tenantId), slug } },
|
||||
include,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createKnowledgeBase(data: {
|
||||
basePath: string;
|
||||
slug: string;
|
||||
tenantId?: string | null;
|
||||
title: string;
|
||||
description: string;
|
||||
defaultLanguage: string;
|
||||
layout: string;
|
||||
color: number;
|
||||
enabled: boolean;
|
||||
languages: string;
|
||||
links: string;
|
||||
logo: string;
|
||||
seoImage: string;
|
||||
}) {
|
||||
return await db.knowledgeBase.create({
|
||||
data: {
|
||||
basePath: data.basePath,
|
||||
slug: data.slug,
|
||||
tenantId: data.tenantId ?? null,
|
||||
ownerKey: knowledgeBaseOwnerKey(data.tenantId),
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
defaultLanguage: data.defaultLanguage,
|
||||
layout: data.layout,
|
||||
color: data.color,
|
||||
enabled: data.enabled,
|
||||
languages: data.languages,
|
||||
links: data.links,
|
||||
logo: data.logo,
|
||||
seoImage: data.seoImage,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateKnowledgeBase(
|
||||
id: string,
|
||||
data: {
|
||||
basePath?: string;
|
||||
slug?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
defaultLanguage?: string;
|
||||
layout?: string;
|
||||
color?: number;
|
||||
enabled?: boolean;
|
||||
languages?: string;
|
||||
links?: string;
|
||||
logo?: string;
|
||||
seoImage?: string;
|
||||
}
|
||||
) {
|
||||
return await db.knowledgeBase
|
||||
.update({
|
||||
where: { id },
|
||||
data: {
|
||||
basePath: data.basePath,
|
||||
slug: data.slug,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
defaultLanguage: data.defaultLanguage,
|
||||
layout: data.layout,
|
||||
color: data.color,
|
||||
enabled: data.enabled,
|
||||
languages: data.languages,
|
||||
links: data.links,
|
||||
logo: data.logo,
|
||||
seoImage: data.seoImage,
|
||||
},
|
||||
})
|
||||
.then((item) => {
|
||||
clearCacheKey(`kb:${item.slug}`);
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteKnowledgeBase(id: string) {
|
||||
return await db.knowledgeBase
|
||||
.delete({
|
||||
where: { id },
|
||||
})
|
||||
.then((item) => {
|
||||
clearCacheKey(`kb:${item.slug}`);
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
export async function countKnowledgeBases() {
|
||||
// Dashboard count — read-only.
|
||||
return await dbRead.knowledgeBase.count();
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
|
||||
|
||||
export type KbArticleDto = {
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date | null;
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
order: number;
|
||||
contentDraft: string;
|
||||
contentPublished: string;
|
||||
contentPublishedAsText: string;
|
||||
contentType: "markdown" | "wysiwyg";
|
||||
language: string;
|
||||
featuredOrder: number | null;
|
||||
createdByUser: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
avatar: string | null;
|
||||
} | null;
|
||||
seoImage: string;
|
||||
publishedAt: Date | null;
|
||||
href: string;
|
||||
sectionId: string | null;
|
||||
relatedArticles: { order: number; title: string; slug: string; href: string }[];
|
||||
metatags: MetaTagsDto;
|
||||
// tags: KnowledgeBaseTagDto[];
|
||||
// reactions: KnowledgeBaseArticleReactionDto[];
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { KbCategorySectionDto } from "./KbCategorySectionDto";
|
||||
import type { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
|
||||
|
||||
export type KbCategoryDto = {
|
||||
id: string;
|
||||
slug: string;
|
||||
order: number;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
language: string;
|
||||
seoImage: string;
|
||||
href: string;
|
||||
sections: KbCategorySectionDto[];
|
||||
articles: {
|
||||
id: string;
|
||||
order: number;
|
||||
title: string;
|
||||
description: string;
|
||||
slug: string;
|
||||
href: string;
|
||||
sectionId: string | null;
|
||||
publishedAt: Date | null;
|
||||
categoryId: string;
|
||||
}[];
|
||||
metatags: MetaTagsDto;
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export type KbCategorySectionDto = {
|
||||
id: string;
|
||||
order: number;
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export type KbNavLinkDto = {
|
||||
name: string;
|
||||
href: string;
|
||||
order: number;
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type KbArticleDto } from "./KbArticleDto";
|
||||
|
||||
export type KbSearchResultDto = {
|
||||
query: string;
|
||||
articles: KbArticleDto[];
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
|
||||
import type { Colors } from "~/application/enums/shared/Colors";
|
||||
import { KbNavLinkDto } from "./KbNavLinkDto";
|
||||
|
||||
export type KnowledgeBaseDto = {
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date | null;
|
||||
basePath: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
defaultLanguage: string;
|
||||
layout: "list" | "articles" | "grid" | "docs";
|
||||
color: Colors;
|
||||
enabled: boolean;
|
||||
metatags: MetaTagsDto;
|
||||
languages: string[];
|
||||
links: KbNavLinkDto[];
|
||||
logo: string;
|
||||
seoImage: string;
|
||||
count: {
|
||||
categories: number;
|
||||
articles: number;
|
||||
views: number;
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
export type KnowledgeBasesTemplateDto = {
|
||||
knowledgeBases: {
|
||||
basePath?: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
defaultLanguage: string;
|
||||
layout: string;
|
||||
color: number;
|
||||
enabled: boolean;
|
||||
languages: string[];
|
||||
links: { name: string; href: string; order: number }[];
|
||||
logo: string;
|
||||
seoImage: string;
|
||||
}[];
|
||||
categories: {
|
||||
knowledgeBaseSlug: string;
|
||||
slug: string;
|
||||
order: number;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
language: string;
|
||||
seoImage: string;
|
||||
sections: {
|
||||
order: number;
|
||||
title: string;
|
||||
description: string;
|
||||
}[];
|
||||
}[];
|
||||
articles: {
|
||||
knowledgeBaseSlug: string;
|
||||
categorySlug: string | null;
|
||||
categorySectionOrder: number | null;
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
order: number;
|
||||
contentDraft: string;
|
||||
contentPublished: string;
|
||||
contentPublishedAsText: string;
|
||||
contentType: string;
|
||||
language: string;
|
||||
featuredOrder: number | null;
|
||||
seoImage: string;
|
||||
publishedAt: string | null;
|
||||
createdByUserEmail: string | null;
|
||||
relatedArticles: { slug: string }[];
|
||||
}[];
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { KnowledgeBaseCategory } from "@toolctrl/db/prisma-browser";
|
||||
|
||||
export type KnowledgeBaseCategoryWithDetails = KnowledgeBaseCategory & {
|
||||
articles: {
|
||||
id: string;
|
||||
order: number;
|
||||
title: string;
|
||||
description: string;
|
||||
slug: string;
|
||||
language: string;
|
||||
sectionId: string | null;
|
||||
publishedAt: Date | null;
|
||||
}[];
|
||||
sections: {
|
||||
id: string;
|
||||
order: number;
|
||||
title: string;
|
||||
description: string;
|
||||
}[];
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
import { ActionFunctionArgs, LoaderFunctionArgs, redirect } from "react-router";
|
||||
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
|
||||
import { getUserInfo } from "~/utils/session.server";
|
||||
import { createKnowledgeBaseArticleView, getArticleStateByUserAnalyticsId, voteArticle } from "../../db/kbAnalytics.db.server";
|
||||
import { KbArticleDto } from "../../dtos/KbArticleDto";
|
||||
import { KbCategoryDto } from "../../dtos/KbCategoryDto";
|
||||
import { KnowledgeBaseDto } from "../../dtos/KnowledgeBaseDto";
|
||||
import KnowledgeBaseService from "../../service/KnowledgeBaseService.server";
|
||||
import KnowledgeBaseUtils from "../../utils/KnowledgeBaseUtils";
|
||||
import { KbSearchResultDto } from "../../dtos/KbSearchResultDto";
|
||||
import { getUser } from "~/utils/db/users.db.server";
|
||||
import { getAnalyticsInfo } from "~/utils/analyticsCookie.server";
|
||||
import { getTranslations } from "~/locale/i18next.server";
|
||||
|
||||
export namespace KbRoutesArticleApi {
|
||||
export type LoaderData = {
|
||||
metatags?: MetaTagsDto;
|
||||
kb: KnowledgeBaseDto;
|
||||
search: KbSearchResultDto | undefined;
|
||||
item: {
|
||||
article: KbArticleDto;
|
||||
category: KbCategoryDto;
|
||||
} | null;
|
||||
userState: {
|
||||
hasThumbsUp: boolean;
|
||||
hasThumbsDown: boolean;
|
||||
};
|
||||
isAdmin: boolean;
|
||||
categories: KbCategoryDto[];
|
||||
};
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs, { kbSlug }: { kbSlug?: string } = {}) => {
|
||||
const analyticsInfo = await getAnalyticsInfo(request);
|
||||
const userInfo = await getUserInfo(request);
|
||||
const kb = await KnowledgeBaseService.get({ slug: kbSlug ?? params.slug!, enabled: true, request });
|
||||
const item = await KnowledgeBaseService.getArticle({
|
||||
kb,
|
||||
slug: params.article ?? "",
|
||||
params,
|
||||
request,
|
||||
});
|
||||
if (!item) {
|
||||
throw redirect(KnowledgeBaseUtils.getKbUrl({ kb, params }));
|
||||
}
|
||||
|
||||
if (item?.article) {
|
||||
createKnowledgeBaseArticleView({ userAnalyticsId: analyticsInfo.userAnalyticsId, articleId: item.article.id });
|
||||
}
|
||||
let userState = await getArticleStateByUserAnalyticsId({
|
||||
userAnalyticsId: analyticsInfo.userAnalyticsId,
|
||||
articleId: item?.article.id ?? "",
|
||||
});
|
||||
const searchParams = new URL(request.url).searchParams;
|
||||
const query = searchParams.get("q")?.toString();
|
||||
const currentUser = await getUser(userInfo.userId);
|
||||
KnowledgeBaseUtils.fixDarkMode({ article: item.article });
|
||||
const data: LoaderData = {
|
||||
metatags: item?.article.metatags,
|
||||
kb,
|
||||
search: await KnowledgeBaseService.search({ query, kb, params, request }),
|
||||
item,
|
||||
userState,
|
||||
isAdmin: !!currentUser?.admin,
|
||||
categories: await KnowledgeBaseService.getCategories({
|
||||
kb,
|
||||
params,
|
||||
request,
|
||||
}),
|
||||
};
|
||||
return data;
|
||||
};
|
||||
|
||||
export const action = async ({ request, params }: ActionFunctionArgs, { kbSlug }: { kbSlug?: string } = {}) => {
|
||||
const { t } = await getTranslations(request);
|
||||
const { userAnalyticsId } = await getAnalyticsInfo(request);
|
||||
const form = await request.formData();
|
||||
const action = form.get("action") as string;
|
||||
const kb = await KnowledgeBaseService.get({ slug: kbSlug ?? params.slug!, enabled: true, request });
|
||||
const item = await KnowledgeBaseService.getArticle({
|
||||
kb,
|
||||
slug: params.article ?? "",
|
||||
params,
|
||||
request,
|
||||
});
|
||||
if (!item) {
|
||||
return Response.json({ error: t("knowledgeBase.public.errors.articleNotFound") }, { status: 404 });
|
||||
}
|
||||
if (action === "thumbsUp") {
|
||||
await voteArticle({ userAnalyticsId, articleId: item.article.id, type: "up" });
|
||||
return Response.json({ success: true });
|
||||
} else if (action === "thumbsDown") {
|
||||
await voteArticle({ userAnalyticsId, articleId: item.article.id, type: "down" });
|
||||
return Response.json({ success: true });
|
||||
}
|
||||
|
||||
return Response.json({ error: t("knowledgeBase.public.errors.invalidAction") }, { status: 400 });
|
||||
};
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import { LoaderFunctionArgs, redirect } from "react-router";
|
||||
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
|
||||
import { KbCategoryDto } from "../../dtos/KbCategoryDto";
|
||||
import { KnowledgeBaseDto } from "../../dtos/KnowledgeBaseDto";
|
||||
import KnowledgeBaseService from "../../service/KnowledgeBaseService.server";
|
||||
import KnowledgeBaseUtils from "../../utils/KnowledgeBaseUtils";
|
||||
import { KbSearchResultDto } from "../../dtos/KbSearchResultDto";
|
||||
|
||||
export namespace KbRoutesCategoryApi {
|
||||
export type LoaderData = {
|
||||
metatags?: MetaTagsDto;
|
||||
kb: KnowledgeBaseDto;
|
||||
search: KbSearchResultDto | undefined;
|
||||
item: KbCategoryDto | null;
|
||||
language: string;
|
||||
allCategories: KbCategoryDto[];
|
||||
};
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs, { kbSlug }: { kbSlug?: string } = {}) => {
|
||||
const kb = await KnowledgeBaseService.get({ slug: kbSlug ?? params.slug!, enabled: true, request });
|
||||
const language = params.lang ?? kb.defaultLanguage;
|
||||
|
||||
const item = await KnowledgeBaseService.getCategory({
|
||||
kb,
|
||||
category: params.category ?? "",
|
||||
language,
|
||||
params,
|
||||
request,
|
||||
});
|
||||
if (!item) {
|
||||
throw redirect(KnowledgeBaseUtils.getKbUrl({ kb, params }));
|
||||
}
|
||||
const searchParams = new URL(request.url).searchParams;
|
||||
const query = searchParams.get("q")?.toString();
|
||||
const data: LoaderData = {
|
||||
metatags: item?.metatags,
|
||||
kb,
|
||||
search: await KnowledgeBaseService.search({ query, kb, params, request }),
|
||||
item,
|
||||
allCategories: await KnowledgeBaseService.getCategories({
|
||||
kb,
|
||||
params,
|
||||
request,
|
||||
}),
|
||||
language,
|
||||
};
|
||||
return data;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { LoaderFunctionArgs } from "react-router";
|
||||
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
|
||||
import { createKnowledgeBaseView } from "../../db/kbAnalytics.db.server";
|
||||
import { KbArticleDto } from "../../dtos/KbArticleDto";
|
||||
import { KbCategoryDto } from "../../dtos/KbCategoryDto";
|
||||
import { KnowledgeBaseDto } from "../../dtos/KnowledgeBaseDto";
|
||||
import KnowledgeBaseService from "../../service/KnowledgeBaseService.server";
|
||||
import { KbSearchResultDto } from "../../dtos/KbSearchResultDto";
|
||||
import KnowledgeBaseUtils from "../../utils/KnowledgeBaseUtils";
|
||||
import { getAnalyticsInfo } from "~/utils/analyticsCookie.server";
|
||||
import { getUserInfo } from "~/utils/session.server";
|
||||
import { getUser } from "~/utils/db/users.db.server";
|
||||
|
||||
export namespace KbRoutesIndexApi {
|
||||
export type LoaderData = {
|
||||
metatags: MetaTagsDto;
|
||||
kb: KnowledgeBaseDto;
|
||||
search: KbSearchResultDto | undefined;
|
||||
categories: KbCategoryDto[];
|
||||
featured: KbArticleDto[];
|
||||
isAdmin: boolean;
|
||||
};
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs, { kbSlug }: { kbSlug?: string } = {}) => {
|
||||
if (params.lang && !KnowledgeBaseUtils.supportedLanguages.find((f) => f.value === params.lang)) {
|
||||
throw Response.json({}, { status: 404 });
|
||||
}
|
||||
const kb = await KnowledgeBaseService.get({ slug: kbSlug ?? params.slug!, enabled: true, request });
|
||||
|
||||
const { userAnalyticsId } = await getAnalyticsInfo(request);
|
||||
const userInfo = await getUserInfo(request);
|
||||
|
||||
await createKnowledgeBaseView({ userAnalyticsId, knowledgeBaseId: kb.id });
|
||||
const searchParams = new URL(request.url).searchParams;
|
||||
const query = searchParams.get("q")?.toString();
|
||||
const currentUser = await getUser(userInfo.userId);
|
||||
const data: LoaderData = {
|
||||
metatags: kb.metatags,
|
||||
kb,
|
||||
search: await KnowledgeBaseService.search({ query, kb, params, request }),
|
||||
categories: await KnowledgeBaseService.getCategories({
|
||||
kb,
|
||||
params,
|
||||
request,
|
||||
}),
|
||||
featured: await KnowledgeBaseService.getFeaturedArticles({
|
||||
kb,
|
||||
params,
|
||||
request,
|
||||
}),
|
||||
isAdmin: !!currentUser?.admin,
|
||||
// searchResult,
|
||||
};
|
||||
return data;
|
||||
};
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
import { useLoaderData, useSubmit } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import KbHeader from "../../components/KbHeader";
|
||||
import KbArticle from "../../components/articles/KbArticle";
|
||||
import { KbRoutesArticleApi } from "../api/KbRoutes.Article.Api";
|
||||
import KbDocsLayout from "../../components/layouts/KbDocsLayout";
|
||||
|
||||
export default function KbRoutesArticleView() {
|
||||
const data = useLoaderData<KbRoutesArticleApi.LoaderData>();
|
||||
const submit = useSubmit();
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (data.kb.layout === "docs") {
|
||||
return (
|
||||
<KbDocsLayout
|
||||
kb={data.kb}
|
||||
categories={data.categories}
|
||||
article={!data.item ? null : { ...data.item, userState: data.userState }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function onAction(name: string) {
|
||||
const form = new FormData();
|
||||
form.set("action", name);
|
||||
submit(form, {
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
return (
|
||||
<div className="bg-background text-foreground min-h-screen">
|
||||
<KbHeader kb={data.kb} withTitleAndDescription={false} withSearch={false} />
|
||||
|
||||
<div className="mx-auto max-w-5xl px-8 py-8">
|
||||
<div className="space-y-3">
|
||||
{!data.item ? (
|
||||
<div>{t("knowledgeBase.public.errors.articleNotFound")}</div>
|
||||
) : (
|
||||
<KbArticle
|
||||
kb={data.kb}
|
||||
category={data.item.category}
|
||||
item={data.item.article}
|
||||
userState={{
|
||||
hasThumbsUp: data.userState.hasThumbsUp,
|
||||
hasThumbsDown: data.userState.hasThumbsDown,
|
||||
}}
|
||||
actions={{
|
||||
onThumbsUp: () => onAction("thumbsUp"),
|
||||
onThumbsDown: () => onAction("thumbsDown"),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import { useLoaderData } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import KbHeader from "../../components/KbHeader";
|
||||
import KbCategory from "../../components/categories/KbCategory";
|
||||
import { KbRoutesCategoryApi } from "../api/KbRoutes.Category.Api";
|
||||
|
||||
export default function KbRoutesCategoryView() {
|
||||
const data = useLoaderData<KbRoutesCategoryApi.LoaderData>();
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="bg-background text-foreground min-h-screen">
|
||||
<KbHeader kb={data.kb} withTitleAndDescription={false} />
|
||||
<div className="mx-auto min-h-screen max-w-5xl px-8 py-8">
|
||||
<div className="space-y-5">{!data.item ? <div>{t("knowledgeBase.public.errors.categoryNotFound")}</div> : <KbCategory kb={data.kb} item={data.item} allCategories={data.allCategories} />}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import { KbRoutesIndexApi } from "../api/KbRoutes.Index.Api";
|
||||
import KbHeader from "../../components/KbHeader";
|
||||
import KbFeaturedArticles from "../../components/KbFeaturedArticles";
|
||||
import KbCategories from "../../components/categories/KbCategories";
|
||||
import KbDocsLayout from "../../components/layouts/KbDocsLayout";
|
||||
import { useLoaderData } from "react-router";
|
||||
|
||||
export default function KbRoutesIndex() {
|
||||
const data = useLoaderData<KbRoutesIndexApi.LoaderData>();
|
||||
|
||||
if (data.kb.layout === "docs") {
|
||||
return <KbDocsLayout kb={data.kb} categories={data.categories} featured={data.featured} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-background text-foreground min-h-screen">
|
||||
<KbHeader kb={data.kb} withTitleAndDescription={true} />
|
||||
<div className="mx-auto max-w-5xl px-2 py-6 sm:px-8">
|
||||
<div className="space-y-8">
|
||||
{data.featured.length > 0 && <KbFeaturedArticles kb={data.kb} items={data.featured} />}
|
||||
<KbCategories kb={data.kb} items={data.categories} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const getAllKnowledgeBaseArticlesMock = vi.fn();
|
||||
const createKnowledgeBaseArticleMock = vi.fn();
|
||||
const getAllKnowledgeBaseCategoriesMock = vi.fn();
|
||||
const getFixedTMock = vi.fn();
|
||||
const knowledgeBaseArticleCountMock = vi.fn();
|
||||
const knowledgeBaseCategoryCountMock = vi.fn();
|
||||
const knowledgeBaseDeleteMock = vi.fn();
|
||||
const cachifiedMock = vi.fn();
|
||||
|
||||
vi.mock("../db/kbArticles.db.server", () => ({
|
||||
createKnowledgeBaseArticle: (...args: unknown[]) => createKnowledgeBaseArticleMock(...args),
|
||||
getAllKnowledgeBaseArticles: (...args: unknown[]) => getAllKnowledgeBaseArticlesMock(...args),
|
||||
getFeaturedKnowledgeBaseArticles: vi.fn(),
|
||||
getKbArticleById: vi.fn(),
|
||||
getKbArticleByIdInKnowledgeBase: vi.fn(),
|
||||
getKbArticleBySlug: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../db/kbCategories.db.server", () => ({
|
||||
createKnowledgeBaseCategory: vi.fn(),
|
||||
getAllKnowledgeBaseCategories: (...args: unknown[]) => getAllKnowledgeBaseCategoriesMock(...args),
|
||||
getKbCategoryById: vi.fn(),
|
||||
getKbCategoryBySlug: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../db/knowledgeBase.db.server", () => ({
|
||||
getAllKnowledgeBases: vi.fn(),
|
||||
getKnowledgeBaseById: vi.fn(),
|
||||
getKnowledgeBaseBySlug: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../services/kbArticleSearch.server", () => ({
|
||||
searchKnowledgeBaseArticles: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("~/locale/i18next.server", () => ({
|
||||
remixI18Next: {
|
||||
getFixedT: (...args: unknown[]) => getFixedTMock(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("~/utils/cache.server", () => ({
|
||||
cachified: (...args: unknown[]) => cachifiedMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("~/utils/db.server", () => ({
|
||||
db: {
|
||||
knowledgeBaseArticle: { count: (...args: unknown[]) => knowledgeBaseArticleCountMock(...args) },
|
||||
knowledgeBaseCategory: { count: (...args: unknown[]) => knowledgeBaseCategoryCountMock(...args) },
|
||||
knowledgeBase: { delete: (...args: unknown[]) => knowledgeBaseDeleteMock(...args) },
|
||||
},
|
||||
}));
|
||||
|
||||
describe("KnowledgeBaseServiceServer", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getAllKnowledgeBaseArticlesMock.mockResolvedValue([
|
||||
{
|
||||
id: "article-1",
|
||||
slug: "untitled",
|
||||
order: 1,
|
||||
},
|
||||
]);
|
||||
getAllKnowledgeBaseCategoriesMock.mockResolvedValue([]);
|
||||
createKnowledgeBaseArticleMock.mockImplementation(async (input) => ({ id: "created", ...input }));
|
||||
getFixedTMock.mockImplementation(async (locale: string) => (key: string, vars?: Record<string, unknown>) => `${locale}:${key}:${vars?.number ?? ""}`);
|
||||
knowledgeBaseArticleCountMock.mockResolvedValue(0);
|
||||
knowledgeBaseCategoryCountMock.mockResolvedValue(0);
|
||||
knowledgeBaseDeleteMock.mockResolvedValue({ id: "kb-1" });
|
||||
cachifiedMock.mockImplementation(async ({ getFreshValue }: { getFreshValue: () => unknown }) => getFreshValue());
|
||||
});
|
||||
|
||||
test("newArticle localizes generated untitled titles and persists the resolved language", async () => {
|
||||
const KnowledgeBaseServiceServer = (await import("./KnowledgeBaseService.server")).default;
|
||||
|
||||
const created = await KnowledgeBaseServiceServer.newArticle({
|
||||
kb: {
|
||||
id: "kb-1",
|
||||
slug: "docs",
|
||||
defaultLanguage: "de",
|
||||
} as never,
|
||||
params: {},
|
||||
userId: "user-1",
|
||||
position: "last",
|
||||
});
|
||||
|
||||
expect(getFixedTMock).toHaveBeenCalledWith("de", "translations");
|
||||
expect(createKnowledgeBaseArticleMock).toHaveBeenCalledWith(expect.objectContaining({ title: "de:knowledgeBase.adminCategories.untitledNumbered:1", language: "de" }));
|
||||
expect(created).toMatchObject({ title: "de:knowledgeBase.adminCategories.untitledNumbered:1", language: "de" });
|
||||
});
|
||||
|
||||
test("del exposes coded delete guard errors for localized route handling", async () => {
|
||||
const { default: KnowledgeBaseServiceServer, getKnowledgeBaseActionErrorKey } = await import("./KnowledgeBaseService.server");
|
||||
knowledgeBaseArticleCountMock.mockResolvedValueOnce(1);
|
||||
|
||||
try {
|
||||
await KnowledgeBaseServiceServer.del({ id: "kb-1" } as never);
|
||||
throw new Error("Expected delete to reject");
|
||||
} catch (error) {
|
||||
expect(error).toMatchObject({ code: "cannot-delete-with-articles" });
|
||||
expect(getKnowledgeBaseActionErrorKey(error)).toBe("knowledgeBase.admin.errors.cannotDeleteWithArticles");
|
||||
}
|
||||
});
|
||||
|
||||
test("duplicate and new article validation errors map to semantic i18n keys", async () => {
|
||||
const { default: KnowledgeBaseServiceServer, getKnowledgeBaseActionErrorKey } = await import("./KnowledgeBaseService.server");
|
||||
|
||||
await expect(
|
||||
KnowledgeBaseServiceServer.duplicateArticle({ kb: { id: "kb-1", slug: "docs", defaultLanguage: "en" } as never, language: "en", articleId: "missing" })
|
||||
).rejects.toMatchObject({ code: "invalid-article" });
|
||||
|
||||
try {
|
||||
await KnowledgeBaseServiceServer.newArticle({
|
||||
kb: { id: "kb-1", slug: "docs", defaultLanguage: "en" } as never,
|
||||
params: {},
|
||||
userId: "user-1",
|
||||
position: "last",
|
||||
categoryId: "missing",
|
||||
});
|
||||
} catch (error) {
|
||||
expect(getKnowledgeBaseActionErrorKey(error)).toBe("knowledgeBase.adminCategories.errors.categoryNotFound");
|
||||
}
|
||||
});
|
||||
|
||||
test("public lookup errors map to public i18n keys", async () => {
|
||||
const { default: KnowledgeBaseServiceServer, getKnowledgeBasePublicErrorKey } = await import("./KnowledgeBaseService.server");
|
||||
cachifiedMock.mockResolvedValueOnce(null);
|
||||
|
||||
try {
|
||||
await KnowledgeBaseServiceServer.get({ slug: "missing", enabled: true, request: new Request("https://example.com/help") });
|
||||
throw new Error("Expected lookup to reject");
|
||||
} catch (error) {
|
||||
expect(error).toMatchObject({ code: "not-found" });
|
||||
expect(getKnowledgeBasePublicErrorKey(error)).toBe("knowledgeBase.public.errors.notFound");
|
||||
}
|
||||
|
||||
cachifiedMock.mockResolvedValueOnce({ title: "Draft docs", enabled: false });
|
||||
try {
|
||||
await KnowledgeBaseServiceServer.get({ slug: "draft", enabled: true, request: new Request("https://example.com/help") });
|
||||
throw new Error("Expected disabled knowledge base to reject");
|
||||
} catch (error) {
|
||||
expect(error).toMatchObject({ code: "disabled" });
|
||||
expect(getKnowledgeBasePublicErrorKey(error)).toBe("knowledgeBase.public.errors.disabled");
|
||||
}
|
||||
});
|
||||
});
|
||||
+648
@@ -0,0 +1,648 @@
|
||||
import type { KbArticleDto } from "../dtos/KbArticleDto";
|
||||
import type { KbCategoryDto } from "../dtos/KbCategoryDto";
|
||||
import type { KbSearchResultDto } from "../dtos/KbSearchResultDto";
|
||||
import type { KnowledgeBaseDto } from "../dtos/KnowledgeBaseDto";
|
||||
import { KnowledgeBaseWithDetails, getAllKnowledgeBases, getKnowledgeBaseById, getKnowledgeBaseBySlug } from "../db/knowledgeBase.db.server";
|
||||
import { db } from "~/utils/db.server";
|
||||
import { KbNavLinkDto } from "../dtos/KbNavLinkDto";
|
||||
import {
|
||||
KnowledgeBaseArticleWithDetails,
|
||||
createKnowledgeBaseArticle,
|
||||
getAllKnowledgeBaseArticles,
|
||||
getFeaturedKnowledgeBaseArticles,
|
||||
getKbArticleByIdInKnowledgeBase,
|
||||
getKbArticleBySlug,
|
||||
} from "../db/kbArticles.db.server";
|
||||
import { searchKnowledgeBaseArticles } from "../services/kbArticleSearch.server";
|
||||
import { createKnowledgeBaseCategory, getAllKnowledgeBaseCategories, getKbCategoryById, getKbCategoryBySlug } from "../db/kbCategories.db.server";
|
||||
import KnowledgeBaseUtils from "../utils/KnowledgeBaseUtils";
|
||||
import { KnowledgeBaseCategoryWithDetails } from "../helpers/KbCategoryModelHelper";
|
||||
import { KnowledgeBaseRelatedArticle } from "@toolctrl/db/prisma-browser";
|
||||
import { defaultLocale, isSupportedLocale } from "@toolctrl/i18n";
|
||||
import { marked } from "marked";
|
||||
import { htmlToText } from "html-to-text";
|
||||
import { remixI18Next } from "~/locale/i18next.server";
|
||||
import { cachified } from "~/utils/cache.server";
|
||||
import { getBaseURL } from "~/utils/url.server";
|
||||
|
||||
export type KnowledgeBaseServiceErrorCode =
|
||||
| "cannot-delete-with-articles"
|
||||
| "cannot-delete-with-categories"
|
||||
| "disabled"
|
||||
| "invalid-category"
|
||||
| "invalid-article"
|
||||
| "invalid-section"
|
||||
| "not-found";
|
||||
|
||||
const knowledgeBaseServiceErrorKeys: Record<KnowledgeBaseServiceErrorCode, string> = {
|
||||
"cannot-delete-with-articles": "knowledgeBase.admin.errors.cannotDeleteWithArticles",
|
||||
"cannot-delete-with-categories": "knowledgeBase.admin.errors.cannotDeleteWithCategories",
|
||||
disabled: "knowledgeBase.public.errors.disabled",
|
||||
"invalid-category": "knowledgeBase.adminCategories.errors.categoryNotFound",
|
||||
"invalid-article": "knowledgeBase.adminCategories.errors.articleNotFound",
|
||||
"invalid-section": "knowledgeBase.adminCategories.errors.sectionNotFound",
|
||||
"not-found": "knowledgeBase.public.errors.notFound",
|
||||
};
|
||||
|
||||
export class KnowledgeBaseServiceError extends Error {
|
||||
constructor(readonly code: KnowledgeBaseServiceErrorCode) {
|
||||
super(code);
|
||||
this.name = "KnowledgeBaseServiceError";
|
||||
}
|
||||
}
|
||||
|
||||
export function getKnowledgeBaseActionErrorKey(error: unknown): string {
|
||||
if (error instanceof KnowledgeBaseServiceError) {
|
||||
return knowledgeBaseServiceErrorKeys[error.code];
|
||||
}
|
||||
return "knowledgeBase.admin.errors.actionFailed";
|
||||
}
|
||||
|
||||
export function getKnowledgeBasePublicErrorKey(error: unknown): string {
|
||||
if (error instanceof KnowledgeBaseServiceError && (error.code === "not-found" || error.code === "disabled")) {
|
||||
return knowledgeBaseServiceErrorKeys[error.code];
|
||||
}
|
||||
return "knowledgeBase.public.errors.unavailable";
|
||||
}
|
||||
|
||||
async function getAll({ enabled, request }: { enabled?: boolean; request: Request }): Promise<KnowledgeBaseDto[]> {
|
||||
const items = await getAllKnowledgeBases({ enabled });
|
||||
return items.map((item) => kbToDto(item, request));
|
||||
}
|
||||
|
||||
async function get({ slug, enabled, request }: { slug: string; enabled?: boolean; request: Request }): Promise<KnowledgeBaseDto> {
|
||||
const item = await cachified({
|
||||
key: `kb:${slug}`,
|
||||
ttl: 60 * 60 * 24,
|
||||
getFreshValue: () => getKnowledgeBaseBySlug(slug),
|
||||
});
|
||||
if (!item) {
|
||||
throw new KnowledgeBaseServiceError("not-found");
|
||||
}
|
||||
|
||||
if (enabled !== undefined && item.enabled !== enabled) {
|
||||
throw new KnowledgeBaseServiceError("disabled");
|
||||
}
|
||||
return kbToDto(item, request);
|
||||
}
|
||||
|
||||
async function getById({ id, request }: { id: string; request: Request }): Promise<KnowledgeBaseDto | null> {
|
||||
const item = await getKnowledgeBaseById(id);
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return kbToDto(item, request);
|
||||
}
|
||||
|
||||
function kbToDto(kb: KnowledgeBaseWithDetails, request: Request) {
|
||||
const baseUrl = getBaseURL(request, { https: true });
|
||||
const item: KnowledgeBaseDto = {
|
||||
id: kb.id,
|
||||
createdAt: kb.createdAt,
|
||||
updatedAt: kb.updatedAt,
|
||||
basePath: kb.basePath,
|
||||
slug: kb.slug,
|
||||
title: kb.title,
|
||||
description: kb.description,
|
||||
defaultLanguage: kb.defaultLanguage,
|
||||
layout: kb.layout as "list" | "articles" | "grid",
|
||||
languages: JSON.parse(kb.languages) as string[],
|
||||
links: JSON.parse(kb.links) as KbNavLinkDto[],
|
||||
color: kb.color,
|
||||
enabled: kb.enabled,
|
||||
count: {
|
||||
articles: kb._count.articles,
|
||||
categories: kb._count.categories,
|
||||
views: kb._count.views,
|
||||
},
|
||||
logo: kb.logo,
|
||||
seoImage: kb.seoImage,
|
||||
metatags: [
|
||||
{ title: `${kb.title}` },
|
||||
{ name: "description", content: kb.description },
|
||||
// { name: "keywords", content: kb.keywords },
|
||||
{ property: "og:title", content: `${kb.title}` },
|
||||
{ property: "og:description", content: kb.description },
|
||||
{ property: "og:image", content: kb.seoImage },
|
||||
{ property: "og:type", content: "website" },
|
||||
{ property: "og:url", content: `${baseUrl}${KnowledgeBaseUtils.getKbUrl({ kb, params: {} })}` },
|
||||
{ property: "twitter:title", content: `${kb.title}` },
|
||||
{ property: "twitter:description", content: kb.description },
|
||||
{ property: "twitter:image", content: kb.seoImage },
|
||||
{ property: "twitter:card", content: "summary_large_image" },
|
||||
{
|
||||
tagName: "link",
|
||||
rel: "canonical",
|
||||
href: `${baseUrl}${KnowledgeBaseUtils.getKbUrl({ kb, params: {} })}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
return item;
|
||||
}
|
||||
|
||||
async function getCategories({
|
||||
kb,
|
||||
params,
|
||||
request,
|
||||
}: {
|
||||
kb: KnowledgeBaseDto;
|
||||
params: {
|
||||
lang?: string;
|
||||
};
|
||||
request: Request;
|
||||
}): Promise<KbCategoryDto[]> {
|
||||
const items = await getAllKnowledgeBaseCategories({
|
||||
knowledgeBaseSlug: kb.slug,
|
||||
language: params.lang || kb.defaultLanguage,
|
||||
});
|
||||
items.forEach((category) => {
|
||||
category.articles = category.articles.filter((f) => f.publishedAt);
|
||||
});
|
||||
|
||||
return items.map((f) => categoryToDto({ kb, category: f, params, request }));
|
||||
// let allItems = await generateFakeData(kb);
|
||||
// allItems.categories = allItems.categories.filter((f) => f.language === language);
|
||||
// const result = await fakeSearch({ original: allItems, query });
|
||||
// allItems.categories.forEach((category) => {
|
||||
// category.articles = allItems.articles;
|
||||
// });
|
||||
// return result.categories;
|
||||
}
|
||||
|
||||
function categoryToDto({
|
||||
kb,
|
||||
category,
|
||||
params,
|
||||
request,
|
||||
}: {
|
||||
kb: KnowledgeBaseDto;
|
||||
category: KnowledgeBaseCategoryWithDetails;
|
||||
params: { lang?: string };
|
||||
request: Request;
|
||||
}) {
|
||||
const baseUrl = getBaseURL(request, { https: true });
|
||||
const item: KbCategoryDto = {
|
||||
id: category.id,
|
||||
slug: category.slug,
|
||||
order: category.order,
|
||||
title: category.title,
|
||||
description: category.description,
|
||||
icon: category.icon,
|
||||
language: category.language,
|
||||
seoImage: category.seoImage,
|
||||
href: KnowledgeBaseUtils.getCategoryUrl({ kb, category, params }),
|
||||
sections: category.sections.map((f) => ({
|
||||
id: f.id,
|
||||
order: f.order,
|
||||
title: f.title,
|
||||
description: f.description,
|
||||
})),
|
||||
articles: category.articles.map((f) => ({
|
||||
id: f.id,
|
||||
order: f.order,
|
||||
title: f.title,
|
||||
description: f.description,
|
||||
language: f.language,
|
||||
slug: f.slug,
|
||||
href: KnowledgeBaseUtils.getArticleUrl({ kb, article: f, params }),
|
||||
sectionId: f.sectionId,
|
||||
publishedAt: f.publishedAt,
|
||||
categoryId: f.id,
|
||||
})),
|
||||
metatags: [
|
||||
{ title: `${category.title} | ${kb.title}` },
|
||||
{ name: "description", content: category.description },
|
||||
// { name: "keywords", content: category.keywords },
|
||||
{ property: "og:title", content: `${category.title} | ${kb.title}` },
|
||||
{ property: "og:description", content: category.description },
|
||||
{ property: "og:image", content: category.seoImage },
|
||||
{ property: "og:type", content: "website" },
|
||||
{ property: "og:url", content: `${baseUrl}${KnowledgeBaseUtils.getCategoryUrl({ kb, category, params })}` },
|
||||
{ property: "twitter:title", content: `${category.title} | ${kb.title}` },
|
||||
{ property: "twitter:description", content: category.description },
|
||||
{ property: "twitter:image", content: category.seoImage },
|
||||
{ property: "twitter:card", content: "summary_large_image" },
|
||||
{
|
||||
tagName: "link",
|
||||
rel: "canonical",
|
||||
href: `${baseUrl}${KnowledgeBaseUtils.getCategoryUrl({ kb, category, params })}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
return item;
|
||||
}
|
||||
|
||||
async function getArticles({
|
||||
kb,
|
||||
categoryId,
|
||||
language,
|
||||
params,
|
||||
request,
|
||||
}: {
|
||||
kb: KnowledgeBaseDto;
|
||||
categoryId: string;
|
||||
language: string;
|
||||
query: string | undefined;
|
||||
params: { lang?: string };
|
||||
request: Request;
|
||||
}): Promise<KbArticleDto[]> {
|
||||
const items = await getAllKnowledgeBaseArticles({
|
||||
knowledgeBaseSlug: kb.slug,
|
||||
categoryId,
|
||||
language,
|
||||
});
|
||||
return items.map((f) => articleToDto({ kb, article: f, relatedArticles: f.relatedArticles, params, request }));
|
||||
// let allItems = await generateFakeData(kb);
|
||||
// allItems.articles = allItems.articles.filter((f) => f.language === language);
|
||||
// const result = await fakeSearch({ original: allItems, query });
|
||||
// return result.articles;
|
||||
}
|
||||
|
||||
async function getArticle({ kb, slug, params, request }: { kb: KnowledgeBaseDto; slug: string; params: { lang?: string }; request: Request }): Promise<{
|
||||
article: KbArticleDto;
|
||||
category: KbCategoryDto;
|
||||
} | null> {
|
||||
let language = params.lang || kb.defaultLanguage;
|
||||
const item = await getKbArticleBySlug({
|
||||
knowledgeBaseId: kb.id,
|
||||
slug,
|
||||
language,
|
||||
});
|
||||
if (!item || !item.publishedAt) {
|
||||
return null;
|
||||
}
|
||||
if (!item.categoryId) {
|
||||
return null;
|
||||
}
|
||||
const category = await getKbCategoryById(item.categoryId);
|
||||
if (!category) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
article: articleToDto({ kb, article: item, relatedArticles: item.relatedArticles, params, request }),
|
||||
category: categoryToDto({ kb, category, params, request }),
|
||||
};
|
||||
// const getIdFromSlug = (slug: string) => {
|
||||
// return slug.split("-").pop();
|
||||
// };
|
||||
// const id = getIdFromSlug(slug);
|
||||
// const allItems = await generateFakeData(kb);
|
||||
// const article = allItems.articles.find((f) => f.id === id && f.language === language);
|
||||
|
||||
// if (!article) {
|
||||
// return null;
|
||||
// }
|
||||
// const category = allItems.categories[0];
|
||||
// return { article, category };
|
||||
}
|
||||
|
||||
async function getArticleById({
|
||||
kb,
|
||||
id,
|
||||
params,
|
||||
request,
|
||||
}: {
|
||||
kb: KnowledgeBaseDto;
|
||||
id: string;
|
||||
params?: { lang?: string };
|
||||
request: Request;
|
||||
}): Promise<KbArticleDto | null> {
|
||||
const item = await getKbArticleByIdInKnowledgeBase({ id, knowledgeBaseId: kb.id, language: params?.lang || kb.defaultLanguage });
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return articleToDto({ kb, article: item, relatedArticles: item.relatedArticles, params: {}, request });
|
||||
}
|
||||
|
||||
async function getFeaturedArticles({ kb, params, request }: { kb: KnowledgeBaseDto; params: { lang?: string }; request: Request }) {
|
||||
const items = await getFeaturedKnowledgeBaseArticles({
|
||||
knowledgeBaseId: kb.id,
|
||||
language: params.lang || kb.defaultLanguage,
|
||||
});
|
||||
return items.map((f) => articleToDto({ kb, article: f, relatedArticles: f.relatedArticles, params, request }));
|
||||
// let allItems = await generateFakeData(kb);
|
||||
// allItems.articles = allItems.articles.filter((f) => f.language === language && f.featuredOrder);
|
||||
// // return first 6
|
||||
// if (allItems.articles.length <= 4) {
|
||||
// return allItems.articles;
|
||||
// }
|
||||
// return allItems.articles.slice(0, 4);
|
||||
}
|
||||
|
||||
function articleToDto({
|
||||
kb,
|
||||
article,
|
||||
relatedArticles,
|
||||
params,
|
||||
request,
|
||||
}: {
|
||||
kb: KnowledgeBaseDto;
|
||||
article: KnowledgeBaseArticleWithDetails;
|
||||
relatedArticles: (KnowledgeBaseRelatedArticle & {
|
||||
relatedArticle: { id: string; order: number; title: string; slug: string };
|
||||
})[];
|
||||
params: { lang?: string };
|
||||
request: Request;
|
||||
}) {
|
||||
const baseUrl = getBaseURL(request, { https: true });
|
||||
const item: KbArticleDto = {
|
||||
id: article.id,
|
||||
createdAt: article.createdAt,
|
||||
updatedAt: article.updatedAt,
|
||||
slug: article.slug,
|
||||
title: article.title,
|
||||
description: article.description,
|
||||
order: article.order,
|
||||
contentDraft: article.contentDraft,
|
||||
contentPublished: article.contentPublished,
|
||||
contentPublishedAsText: article.contentPublishedAsText,
|
||||
contentType: article.contentType as "wysiwyg" | "markdown",
|
||||
language: article.language,
|
||||
featuredOrder: article.featuredOrder,
|
||||
createdByUser: article.createdByUser,
|
||||
publishedAt: article.publishedAt,
|
||||
href: KnowledgeBaseUtils.getArticleUrl({ kb, article, params }),
|
||||
sectionId: article.sectionId,
|
||||
relatedArticles: relatedArticles.map((f) => ({
|
||||
order: f.relatedArticle.order,
|
||||
title: f.relatedArticle.title,
|
||||
slug: f.relatedArticle.slug,
|
||||
href: KnowledgeBaseUtils.getArticleUrl({ kb, article: f.relatedArticle, params }),
|
||||
})),
|
||||
seoImage: article.seoImage,
|
||||
metatags: [
|
||||
{ title: `${article.title} | ${kb.title}` },
|
||||
{ name: "description", content: article.description },
|
||||
// { name: "keywords", content: article.keywords },
|
||||
{ property: "og:title", content: `${article.title}` },
|
||||
{ property: "og:description", content: article.description },
|
||||
{ property: "og:image", content: article.seoImage },
|
||||
{ property: "og:type", content: "article" },
|
||||
{ property: "og:url", content: `${baseUrl}${KnowledgeBaseUtils.getArticleUrl({ kb, article, params })}` },
|
||||
{ property: "twitter:title", content: `${article.title}` },
|
||||
{ property: "twitter:description", content: article.description },
|
||||
{ property: "twitter:image", content: article.seoImage },
|
||||
{ property: "twitter:card", content: "summary_large_image" },
|
||||
{
|
||||
tagName: "link",
|
||||
rel: "canonical",
|
||||
href: `${baseUrl}${KnowledgeBaseUtils.getArticleUrl({ kb, article, params })}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
return item;
|
||||
}
|
||||
|
||||
async function getCategory({
|
||||
kb,
|
||||
category,
|
||||
language,
|
||||
params,
|
||||
request,
|
||||
}: {
|
||||
kb: KnowledgeBaseDto;
|
||||
category: string;
|
||||
language: string;
|
||||
params: { lang?: string };
|
||||
request: Request;
|
||||
}): Promise<KbCategoryDto | null> {
|
||||
const item = await getKbCategoryBySlug({
|
||||
knowledgeBaseId: kb.id,
|
||||
slug: category,
|
||||
language,
|
||||
});
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
item.articles = item.articles.filter((f) => f.publishedAt);
|
||||
return categoryToDto({ kb, category: item, params, request });
|
||||
}
|
||||
|
||||
async function del(item: KnowledgeBaseDto) {
|
||||
const articlesCount = await db.knowledgeBaseArticle.count({
|
||||
where: { knowledgeBaseId: item.id },
|
||||
});
|
||||
const categoriesCount = await db.knowledgeBaseCategory.count({
|
||||
where: { knowledgeBaseId: item.id },
|
||||
});
|
||||
if (articlesCount > 0) {
|
||||
throw new KnowledgeBaseServiceError("cannot-delete-with-articles");
|
||||
}
|
||||
if (categoriesCount > 0) {
|
||||
throw new KnowledgeBaseServiceError("cannot-delete-with-categories");
|
||||
}
|
||||
return await db.knowledgeBase.delete({
|
||||
where: { id: item.id },
|
||||
});
|
||||
}
|
||||
|
||||
async function duplicateCategory({ kb, language, categoryId }: { kb: KnowledgeBaseDto; language: string; categoryId: string }) {
|
||||
const allCategories = await getAllKnowledgeBaseCategories({
|
||||
knowledgeBaseSlug: kb.slug,
|
||||
language,
|
||||
});
|
||||
const existing = allCategories.find((p) => p.id === categoryId);
|
||||
if (!existing) {
|
||||
throw new KnowledgeBaseServiceError("invalid-category");
|
||||
}
|
||||
let number = 2;
|
||||
let slug = "";
|
||||
|
||||
const findExistingSlug = (slug: string) => {
|
||||
return allCategories.find((p) => p.slug === slug);
|
||||
};
|
||||
|
||||
do {
|
||||
slug = `${existing.slug}${number}`;
|
||||
const existingWithSlug = findExistingSlug(slug);
|
||||
if (!existingWithSlug) {
|
||||
break;
|
||||
}
|
||||
// if (number > 10) {
|
||||
// throw Error("Too many duplicates");
|
||||
// }
|
||||
number++;
|
||||
} while (true);
|
||||
|
||||
let maxOrder = 0;
|
||||
if (allCategories.length > 0) {
|
||||
maxOrder = Math.max(...allCategories.map((p) => p.order));
|
||||
}
|
||||
|
||||
const item = await createKnowledgeBaseCategory({
|
||||
knowledgeBaseId: kb.id,
|
||||
slug,
|
||||
order: maxOrder + 1,
|
||||
title: existing.title + " " + number,
|
||||
description: existing.description,
|
||||
icon: existing.icon,
|
||||
language: existing.language,
|
||||
seoImage: existing.seoImage,
|
||||
});
|
||||
return item;
|
||||
}
|
||||
|
||||
async function duplicateArticle({ kb, language, articleId }: { kb: KnowledgeBaseDto; language: string; articleId: string }) {
|
||||
const allArticles = await getAllKnowledgeBaseArticles({
|
||||
knowledgeBaseSlug: kb.slug,
|
||||
language,
|
||||
});
|
||||
|
||||
const existing = allArticles.find((p) => p.id === articleId);
|
||||
if (!existing) {
|
||||
throw new KnowledgeBaseServiceError("invalid-article");
|
||||
}
|
||||
|
||||
const { slug, maxOrder, number } = KnowledgeBaseUtils.getAvailableArticleSlug({
|
||||
allArticles,
|
||||
initialSlug: existing.slug,
|
||||
});
|
||||
|
||||
const item = await createKnowledgeBaseArticle({
|
||||
knowledgeBaseId: kb.id,
|
||||
categoryId: existing.categoryId,
|
||||
sectionId: existing.sectionId,
|
||||
slug,
|
||||
title: existing.title + " " + number,
|
||||
description: existing.description,
|
||||
order: maxOrder + 1,
|
||||
contentDraft: existing.contentDraft,
|
||||
contentPublished: existing.contentPublished,
|
||||
contentPublishedAsText: existing.contentPublishedAsText,
|
||||
contentType: existing.contentType,
|
||||
language: existing.language,
|
||||
featuredOrder: existing.featuredOrder,
|
||||
createdByUserId: existing.createdByUserId,
|
||||
seoImage: existing.seoImage,
|
||||
publishedAt: null,
|
||||
});
|
||||
return item;
|
||||
}
|
||||
|
||||
async function search({
|
||||
query,
|
||||
kb,
|
||||
params,
|
||||
request,
|
||||
}: {
|
||||
query: string | undefined;
|
||||
kb: KnowledgeBaseDto;
|
||||
params: { lang?: string };
|
||||
request: Request;
|
||||
}): Promise<KbSearchResultDto | undefined> {
|
||||
if (!query) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Hot-path: routes the read through @toolctrl/search's tenant-scoped
|
||||
// wrapper when MEILI_URL is set; transparently falls back to the SQL
|
||||
// LIKE-based implementation on connectivity errors or when no
|
||||
// Meilisearch backend is configured. See docs/features/search.md.
|
||||
const kbWithTenant = (await getKnowledgeBaseById(kb.id)) as { tenantId?: string | null } | null;
|
||||
const articles = await searchKnowledgeBaseArticles({
|
||||
knowledgeBaseSlug: kb.slug,
|
||||
language: params.lang || kb.defaultLanguage,
|
||||
query,
|
||||
tenantId: kbWithTenant?.tenantId ?? undefined,
|
||||
});
|
||||
const result: KbSearchResultDto = {
|
||||
articles: articles.map((article) => articleToDto({ kb, article, relatedArticles: [], params, request })),
|
||||
query,
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
async function newArticle({
|
||||
kb,
|
||||
title,
|
||||
categoryId,
|
||||
sectionId,
|
||||
initialSlug = "untitled",
|
||||
params,
|
||||
userId,
|
||||
position,
|
||||
}: {
|
||||
kb: KnowledgeBaseDto;
|
||||
title?: string;
|
||||
categoryId?: string | null;
|
||||
sectionId?: string | null;
|
||||
initialSlug?: string;
|
||||
params: { lang?: string };
|
||||
userId: string;
|
||||
position: "first" | "last";
|
||||
}) {
|
||||
const language = params.lang || kb.defaultLanguage;
|
||||
const allArticles = await getAllKnowledgeBaseArticles({
|
||||
knowledgeBaseSlug: kb.slug,
|
||||
language,
|
||||
});
|
||||
if (categoryId || sectionId) {
|
||||
const allCategories = await getAllKnowledgeBaseCategories({
|
||||
knowledgeBaseSlug: kb.slug,
|
||||
language,
|
||||
});
|
||||
const category = categoryId ? allCategories.find((item) => item.id === categoryId) : null;
|
||||
if (categoryId && !category) {
|
||||
throw new KnowledgeBaseServiceError("invalid-category");
|
||||
}
|
||||
if (sectionId && !category?.sections.some((section) => section.id === sectionId)) {
|
||||
throw new KnowledgeBaseServiceError("invalid-section");
|
||||
}
|
||||
}
|
||||
const { slug, maxOrder, firstOrder, number } = KnowledgeBaseUtils.getAvailableArticleSlug({
|
||||
allArticles,
|
||||
initialSlug,
|
||||
});
|
||||
const created = await createKnowledgeBaseArticle({
|
||||
knowledgeBaseId: kb.id,
|
||||
categoryId: categoryId ?? null,
|
||||
sectionId: sectionId ?? null,
|
||||
slug,
|
||||
title: title ?? (await getUntitledArticleTitle({ language, number })),
|
||||
description: "",
|
||||
order: position === "first" ? firstOrder - 1 : maxOrder + 1,
|
||||
contentDraft: "",
|
||||
contentPublished: "",
|
||||
contentPublishedAsText: "",
|
||||
contentType: "wysiwyg",
|
||||
language,
|
||||
featuredOrder: null,
|
||||
createdByUserId: userId,
|
||||
seoImage: "",
|
||||
publishedAt: null,
|
||||
});
|
||||
return created;
|
||||
}
|
||||
|
||||
async function getUntitledArticleTitle({ language, number }: { language: string | undefined; number: number }) {
|
||||
const locale = language && isSupportedLocale(language) ? language : defaultLocale;
|
||||
const t = await remixI18Next.getFixedT(locale, "translations");
|
||||
return t("knowledgeBase.adminCategories.untitledNumbered", { number }) as string;
|
||||
}
|
||||
|
||||
function contentAsText(text: string) {
|
||||
return htmlToText(marked.parse(text, { async: false }) as string, {
|
||||
wordwrap: 130,
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
getAll,
|
||||
get,
|
||||
getById,
|
||||
getCategories,
|
||||
getArticles,
|
||||
getArticle,
|
||||
getArticleById,
|
||||
getFeaturedArticles,
|
||||
getCategory,
|
||||
del,
|
||||
duplicateCategory,
|
||||
duplicateArticle,
|
||||
search,
|
||||
newArticle,
|
||||
contentAsText,
|
||||
};
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createKnowledgeBaseArticle: vi.fn(),
|
||||
getKbArticleBySlug: vi.fn(),
|
||||
getKnowledgeBaseArticlesForTemplateExport: vi.fn(),
|
||||
updateKnowledgeBaseArticle: vi.fn(),
|
||||
createKnowledgeBaseCategory: vi.fn(),
|
||||
getKbCategoryBySlug: vi.fn(),
|
||||
getKnowledgeBaseCategoriesForTemplateExport: vi.fn(),
|
||||
updateKnowledgeBaseCategory: vi.fn(),
|
||||
createKnowledgeBase: vi.fn(),
|
||||
getAllKnowledgeBases: vi.fn(),
|
||||
getKnowledgeBaseBySlug: vi.fn(),
|
||||
updateKnowledgeBase: vi.fn(),
|
||||
createKnowledgeBaseCategorySection: vi.fn(),
|
||||
updateKnowledgeBaseCategorySection: vi.fn(),
|
||||
adminGetAllUsersNames: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../db/kbArticles.db.server", () => ({
|
||||
createKnowledgeBaseArticle: mocks.createKnowledgeBaseArticle,
|
||||
getKbArticleBySlug: mocks.getKbArticleBySlug,
|
||||
getKnowledgeBaseArticlesForTemplateExport: mocks.getKnowledgeBaseArticlesForTemplateExport,
|
||||
updateKnowledgeBaseArticle: mocks.updateKnowledgeBaseArticle,
|
||||
}));
|
||||
|
||||
vi.mock("../db/kbCategories.db.server", () => ({
|
||||
createKnowledgeBaseCategory: mocks.createKnowledgeBaseCategory,
|
||||
getKbCategoryBySlug: mocks.getKbCategoryBySlug,
|
||||
getKnowledgeBaseCategoriesForTemplateExport: mocks.getKnowledgeBaseCategoriesForTemplateExport,
|
||||
updateKnowledgeBaseCategory: mocks.updateKnowledgeBaseCategory,
|
||||
}));
|
||||
|
||||
vi.mock("../db/knowledgeBase.db.server", () => ({
|
||||
createKnowledgeBase: mocks.createKnowledgeBase,
|
||||
getAllKnowledgeBases: mocks.getAllKnowledgeBases,
|
||||
getKnowledgeBaseBySlug: mocks.getKnowledgeBaseBySlug,
|
||||
updateKnowledgeBase: mocks.updateKnowledgeBase,
|
||||
}));
|
||||
|
||||
vi.mock("../db/kbCategorySections.db.server", () => ({
|
||||
createKnowledgeBaseCategorySection: mocks.createKnowledgeBaseCategorySection,
|
||||
updateKnowledgeBaseCategorySection: mocks.updateKnowledgeBaseCategorySection,
|
||||
}));
|
||||
|
||||
vi.mock("~/utils/db/users.db.server", () => ({
|
||||
adminGetAllUsersNames: mocks.adminGetAllUsersNames,
|
||||
}));
|
||||
|
||||
import KnowledgeBaseTemplatesService from "./KnowledgeBaseTemplatesService.server";
|
||||
|
||||
function knowledgeBase(overrides: { id: string; slug: string; title: string }) {
|
||||
return {
|
||||
id: overrides.id,
|
||||
basePath: "/help",
|
||||
slug: overrides.slug,
|
||||
title: overrides.title,
|
||||
description: `${overrides.title} description`,
|
||||
defaultLanguage: "en",
|
||||
layout: "docs",
|
||||
color: 1,
|
||||
enabled: true,
|
||||
languages: JSON.stringify(["en", "de"]),
|
||||
links: JSON.stringify([{ href: "/help", title: "Help" }]),
|
||||
logo: "",
|
||||
seoImage: "",
|
||||
};
|
||||
}
|
||||
|
||||
describe("KnowledgeBaseTemplatesService", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("exports multiple knowledge bases with flat category and article fetches", async () => {
|
||||
mocks.getAllKnowledgeBases.mockResolvedValue([
|
||||
knowledgeBase({ id: "kb_1", slug: "docs", title: "Docs" }),
|
||||
knowledgeBase({ id: "kb_2", slug: "support", title: "Support" }),
|
||||
]);
|
||||
mocks.getKnowledgeBaseCategoriesForTemplateExport.mockResolvedValue([
|
||||
{
|
||||
id: "cat_1",
|
||||
knowledgeBaseId: "kb_1",
|
||||
slug: "start",
|
||||
order: 1,
|
||||
title: "Start",
|
||||
description: "Start here",
|
||||
icon: "book",
|
||||
language: "en",
|
||||
seoImage: "",
|
||||
sections: [{ id: "section_1", order: 1, title: "Basics", description: "The basics" }],
|
||||
},
|
||||
{
|
||||
id: "cat_2",
|
||||
knowledgeBaseId: "kb_2",
|
||||
slug: "faq",
|
||||
order: 1,
|
||||
title: "FAQ",
|
||||
description: "Answers",
|
||||
icon: "help",
|
||||
language: "en",
|
||||
seoImage: "",
|
||||
sections: [],
|
||||
},
|
||||
]);
|
||||
mocks.getKnowledgeBaseArticlesForTemplateExport.mockResolvedValue([
|
||||
{
|
||||
id: "article_1",
|
||||
knowledgeBaseId: "kb_1",
|
||||
slug: "welcome",
|
||||
title: "Welcome",
|
||||
description: "Welcome article",
|
||||
order: 1,
|
||||
contentDraft: "draft",
|
||||
contentPublished: "published",
|
||||
contentPublishedAsText: "published",
|
||||
contentType: "markdown",
|
||||
language: "en",
|
||||
featuredOrder: null,
|
||||
seoImage: "",
|
||||
publishedAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
category: { slug: "start", title: "Start" },
|
||||
section: { order: 1, title: "Basics" },
|
||||
createdByUser: { email: "author@example.com", firstName: "Ada", lastName: "Lovelace", avatar: null },
|
||||
relatedArticles: [{ relatedArticle: { id: "article_2", order: 2, title: "Next", slug: "next" } }],
|
||||
_count: { views: 0, upvotes: 0, downvotes: 0 },
|
||||
},
|
||||
{
|
||||
id: "article_2",
|
||||
knowledgeBaseId: "kb_2",
|
||||
slug: "faq",
|
||||
title: "FAQ",
|
||||
description: "FAQ article",
|
||||
order: 1,
|
||||
contentDraft: "draft",
|
||||
contentPublished: "published",
|
||||
contentPublishedAsText: "published",
|
||||
contentType: "markdown",
|
||||
language: "en",
|
||||
featuredOrder: 1,
|
||||
seoImage: "",
|
||||
publishedAt: null,
|
||||
category: null,
|
||||
section: null,
|
||||
createdByUser: null,
|
||||
relatedArticles: [],
|
||||
_count: { views: 0, upvotes: 0, downvotes: 0 },
|
||||
},
|
||||
]);
|
||||
|
||||
const template = await KnowledgeBaseTemplatesService.getTemplate();
|
||||
|
||||
expect(mocks.getAllKnowledgeBases).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.getKnowledgeBaseCategoriesForTemplateExport).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.getKnowledgeBaseCategoriesForTemplateExport).toHaveBeenCalledWith({ knowledgeBaseIds: ["kb_1", "kb_2"] });
|
||||
expect(mocks.getKnowledgeBaseArticlesForTemplateExport).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.getKnowledgeBaseArticlesForTemplateExport).toHaveBeenCalledWith({ knowledgeBaseIds: ["kb_1", "kb_2"] });
|
||||
expect(template.knowledgeBases.map((kb) => kb.slug)).toEqual(["docs", "support"]);
|
||||
expect(template.categories.map((category) => [category.knowledgeBaseSlug, category.slug])).toEqual([
|
||||
["docs", "start"],
|
||||
["support", "faq"],
|
||||
]);
|
||||
expect(template.articles.map((article) => [article.knowledgeBaseSlug, article.slug])).toEqual([
|
||||
["docs", "welcome"],
|
||||
["support", "faq"],
|
||||
]);
|
||||
expect(template.articles[0]).toMatchObject({
|
||||
categorySlug: "start",
|
||||
categorySectionOrder: 1,
|
||||
createdByUserEmail: "author@example.com",
|
||||
publishedAt: "2026-01-01T00:00:00.000Z",
|
||||
relatedArticles: [{ slug: "next" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
+291
@@ -0,0 +1,291 @@
|
||||
import type { KnowledgeBase, KnowledgeBaseArticle } from "@toolctrl/db/prisma-browser";
|
||||
import {
|
||||
createKnowledgeBaseArticle,
|
||||
getKbArticleBySlug,
|
||||
getKnowledgeBaseArticlesForTemplateExport,
|
||||
updateKnowledgeBaseArticle,
|
||||
} from "../db/kbArticles.db.server";
|
||||
import {
|
||||
createKnowledgeBaseCategory,
|
||||
getKbCategoryBySlug,
|
||||
getKnowledgeBaseCategoriesForTemplateExport,
|
||||
updateKnowledgeBaseCategory,
|
||||
} from "../db/kbCategories.db.server";
|
||||
import { createKnowledgeBase, getAllKnowledgeBases, getKnowledgeBaseBySlug, updateKnowledgeBase } from "../db/knowledgeBase.db.server";
|
||||
import { KbNavLinkDto } from "../dtos/KbNavLinkDto";
|
||||
import { KnowledgeBasesTemplateDto } from "../dtos/KnowledgeBasesTemplateDto";
|
||||
import { KnowledgeBaseCategoryWithDetails } from "../helpers/KbCategoryModelHelper";
|
||||
import { createKnowledgeBaseCategorySection, updateKnowledgeBaseCategorySection } from "../db/kbCategorySections.db.server";
|
||||
import { adminGetAllUsersNames } from "~/utils/db/users.db.server";
|
||||
|
||||
async function getTemplate(): Promise<KnowledgeBasesTemplateDto> {
|
||||
const template: KnowledgeBasesTemplateDto = {
|
||||
knowledgeBases: [],
|
||||
categories: [],
|
||||
articles: [],
|
||||
};
|
||||
const allKbs = await getAllKnowledgeBases();
|
||||
const categoriesByKnowledgeBaseId = groupByKnowledgeBaseId(
|
||||
await getKnowledgeBaseCategoriesForTemplateExport({ knowledgeBaseIds: allKbs.map((kb) => kb.id) })
|
||||
);
|
||||
const articlesByKnowledgeBaseId = groupByKnowledgeBaseId(await getKnowledgeBaseArticlesForTemplateExport({ knowledgeBaseIds: allKbs.map((kb) => kb.id) }));
|
||||
for (const kb of allKbs) {
|
||||
template.knowledgeBases.push({
|
||||
basePath: kb.basePath,
|
||||
slug: kb.slug,
|
||||
title: kb.title,
|
||||
description: kb.description,
|
||||
defaultLanguage: kb.defaultLanguage,
|
||||
layout: kb.layout,
|
||||
color: kb.color,
|
||||
enabled: kb.enabled,
|
||||
languages: JSON.parse(kb.languages) as string[],
|
||||
links: JSON.parse(kb.links) as KbNavLinkDto[],
|
||||
logo: kb.logo,
|
||||
seoImage: kb.seoImage,
|
||||
});
|
||||
const allCategories = categoriesByKnowledgeBaseId.get(kb.id) ?? [];
|
||||
for (const category of allCategories) {
|
||||
template.categories.push({
|
||||
knowledgeBaseSlug: kb.slug,
|
||||
slug: category.slug,
|
||||
order: category.order,
|
||||
title: category.title,
|
||||
description: category.description,
|
||||
icon: category.icon,
|
||||
language: category.language,
|
||||
seoImage: category.seoImage,
|
||||
sections: category.sections.map((section) => ({
|
||||
order: section.order,
|
||||
title: section.title,
|
||||
description: section.description,
|
||||
})),
|
||||
});
|
||||
}
|
||||
const allArticles = articlesByKnowledgeBaseId.get(kb.id) ?? [];
|
||||
for (const article of allArticles) {
|
||||
template.articles.push({
|
||||
knowledgeBaseSlug: kb.slug,
|
||||
categorySlug: article.category?.slug ?? null,
|
||||
categorySectionOrder: article.section?.order ?? null,
|
||||
slug: article.slug,
|
||||
title: article.title,
|
||||
description: article.description,
|
||||
order: article.order,
|
||||
contentDraft: article.contentDraft,
|
||||
contentPublished: article.contentPublished,
|
||||
contentPublishedAsText: article.contentPublishedAsText,
|
||||
contentType: article.contentType,
|
||||
language: article.language,
|
||||
featuredOrder: article.featuredOrder,
|
||||
seoImage: article.seoImage,
|
||||
publishedAt: article.publishedAt ? article.publishedAt.toISOString() : null,
|
||||
createdByUserEmail: article.createdByUser?.email ?? null,
|
||||
relatedArticles: article.relatedArticles.map(({ relatedArticle }) => ({
|
||||
slug: relatedArticle.slug,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
return template;
|
||||
}
|
||||
|
||||
function groupByKnowledgeBaseId<T extends { knowledgeBaseId: string }>(items: T[]) {
|
||||
const grouped = new Map<string, T[]>();
|
||||
for (const item of items) {
|
||||
const current = grouped.get(item.knowledgeBaseId);
|
||||
if (current) {
|
||||
current.push(item);
|
||||
} else {
|
||||
grouped.set(item.knowledgeBaseId, [item]);
|
||||
}
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
|
||||
async function importKbs({ template, currentUserId }: { template: KnowledgeBasesTemplateDto; currentUserId: string }) {
|
||||
let created = {
|
||||
kbs: 0,
|
||||
categories: 0,
|
||||
sections: 0,
|
||||
articles: 0,
|
||||
};
|
||||
let updated = {
|
||||
kbs: 0,
|
||||
categories: 0,
|
||||
sections: 0,
|
||||
articles: 0,
|
||||
};
|
||||
const allUsers = await adminGetAllUsersNames();
|
||||
for (const kb of template.knowledgeBases) {
|
||||
if (!kb.basePath) {
|
||||
kb.basePath = "/";
|
||||
}
|
||||
let existing: KnowledgeBase | null = await getKnowledgeBaseBySlug(kb.slug);
|
||||
if (existing) {
|
||||
await updateKnowledgeBase(existing.id, {
|
||||
basePath: kb.basePath,
|
||||
slug: kb.slug,
|
||||
title: kb.title,
|
||||
description: kb.description,
|
||||
defaultLanguage: kb.defaultLanguage,
|
||||
layout: kb.layout,
|
||||
color: kb.color,
|
||||
enabled: kb.enabled,
|
||||
languages: JSON.stringify(kb.languages),
|
||||
links: JSON.stringify(kb.links),
|
||||
logo: kb.logo,
|
||||
seoImage: kb.seoImage,
|
||||
});
|
||||
updated.kbs++;
|
||||
} else {
|
||||
existing = await createKnowledgeBase({
|
||||
basePath: kb.basePath,
|
||||
slug: kb.slug,
|
||||
title: kb.title,
|
||||
description: kb.description,
|
||||
defaultLanguage: kb.defaultLanguage,
|
||||
layout: kb.layout,
|
||||
color: kb.color,
|
||||
enabled: kb.enabled,
|
||||
languages: JSON.stringify(kb.languages),
|
||||
links: JSON.stringify(kb.links),
|
||||
logo: kb.logo,
|
||||
seoImage: kb.seoImage,
|
||||
});
|
||||
created.kbs++;
|
||||
}
|
||||
|
||||
for (const category of template.categories.filter((c) => c.knowledgeBaseSlug === kb.slug)) {
|
||||
let existingCategory: KnowledgeBaseCategoryWithDetails | null = await getKbCategoryBySlug({
|
||||
knowledgeBaseId: existing.id,
|
||||
slug: category.slug,
|
||||
language: category.language,
|
||||
});
|
||||
if (existingCategory) {
|
||||
await updateKnowledgeBaseCategory(existingCategory.id, {
|
||||
title: category.title,
|
||||
order: category.order,
|
||||
description: category.description,
|
||||
icon: category.icon,
|
||||
language: category.language,
|
||||
seoImage: category.seoImage,
|
||||
});
|
||||
updated.categories++;
|
||||
} else {
|
||||
existingCategory = await createKnowledgeBaseCategory({
|
||||
knowledgeBaseId: existing.id,
|
||||
slug: category.slug,
|
||||
title: category.title,
|
||||
order: category.order,
|
||||
description: category.description,
|
||||
icon: category.icon,
|
||||
language: category.language,
|
||||
seoImage: category.seoImage,
|
||||
});
|
||||
created.categories++;
|
||||
}
|
||||
|
||||
for (const section of category.sections) {
|
||||
let existingSection: { id: string; order: number; title: string; description: string } | null = null;
|
||||
if (existingCategory) {
|
||||
existingSection = existingCategory.sections.find((s) => s.order === section.order) ?? null;
|
||||
}
|
||||
if (existingSection) {
|
||||
await updateKnowledgeBaseCategorySection(existingSection.id, {
|
||||
title: section.title,
|
||||
description: section.description,
|
||||
});
|
||||
updated.sections++;
|
||||
} else {
|
||||
await createKnowledgeBaseCategorySection({
|
||||
categoryId: existingCategory?.id ?? null,
|
||||
order: section.order,
|
||||
title: section.title,
|
||||
description: section.description,
|
||||
});
|
||||
created.sections++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const article of template.articles.filter((a) => a.knowledgeBaseSlug === kb.slug)) {
|
||||
let existingArticle: KnowledgeBaseArticle | null = await getKbArticleBySlug({
|
||||
knowledgeBaseId: existing.id,
|
||||
slug: article.slug,
|
||||
language: article.language,
|
||||
});
|
||||
let category: KnowledgeBaseCategoryWithDetails | null = null;
|
||||
let sectionId: string | null = null;
|
||||
if (article.categorySlug) {
|
||||
category = await getKbCategoryBySlug({
|
||||
knowledgeBaseId: existing.id,
|
||||
slug: article.categorySlug,
|
||||
language: article.language,
|
||||
});
|
||||
if (category && article.categorySectionOrder) {
|
||||
const section = category.sections.find((s) => s.order === article.categorySectionOrder);
|
||||
if (section) {
|
||||
sectionId = section.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
let createdByUserId = currentUserId;
|
||||
if (article.createdByUserEmail) {
|
||||
const user = allUsers.find((f) => f.email === article.createdByUserEmail);
|
||||
if (user) {
|
||||
createdByUserId = user.id;
|
||||
}
|
||||
}
|
||||
if (existingArticle) {
|
||||
await updateKnowledgeBaseArticle(existingArticle.id, {
|
||||
categoryId: category?.id ?? null,
|
||||
sectionId: sectionId,
|
||||
slug: article.slug,
|
||||
title: article.title,
|
||||
description: article.description,
|
||||
order: article.order,
|
||||
contentDraft: article.contentDraft,
|
||||
contentPublished: article.contentPublished,
|
||||
contentPublishedAsText: article.contentPublishedAsText,
|
||||
contentType: article.contentType,
|
||||
language: article.language,
|
||||
featuredOrder: article.featuredOrder,
|
||||
createdByUserId,
|
||||
seoImage: article.seoImage,
|
||||
publishedAt: article.publishedAt ? new Date(article.publishedAt) : null,
|
||||
});
|
||||
updated.articles++;
|
||||
} else {
|
||||
existingArticle = await createKnowledgeBaseArticle({
|
||||
knowledgeBaseId: existing.id,
|
||||
categoryId: category?.id ?? null,
|
||||
sectionId: sectionId,
|
||||
slug: article.slug,
|
||||
title: article.title,
|
||||
description: article.description,
|
||||
order: article.order,
|
||||
contentDraft: article.contentDraft,
|
||||
contentPublished: article.contentPublished,
|
||||
contentPublishedAsText: article.contentPublishedAsText,
|
||||
contentType: article.contentType,
|
||||
language: article.language,
|
||||
featuredOrder: article.featuredOrder,
|
||||
createdByUserId,
|
||||
seoImage: article.seoImage,
|
||||
publishedAt: article.publishedAt ? new Date(article.publishedAt) : null,
|
||||
});
|
||||
created.articles++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
created,
|
||||
updated,
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
getTemplate,
|
||||
importKbs,
|
||||
};
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createSearchClient: vi.fn(),
|
||||
searchWithFallback: vi.fn(),
|
||||
findKnowledgeBaseArticles: vi.fn(),
|
||||
findKnowledgeBaseArticlesByIds: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@toolctrl/search", () => ({
|
||||
createSearchClient: mocks.createSearchClient,
|
||||
kbArticlesIndex: { name: "kb_articles", primaryKey: "id" },
|
||||
searchWithFallback: mocks.searchWithFallback,
|
||||
}));
|
||||
|
||||
vi.mock("../db/kbArticles.db.server", () => ({
|
||||
findKnowledgeBaseArticles: mocks.findKnowledgeBaseArticles,
|
||||
findKnowledgeBaseArticlesByIds: mocks.findKnowledgeBaseArticlesByIds,
|
||||
}));
|
||||
|
||||
import { searchKnowledgeBaseArticles } from "./kbArticleSearch.server";
|
||||
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
describe("searchKnowledgeBaseArticles", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env.MEILI_URL = "http://meili.test";
|
||||
delete process.env.SEARCH_BACKEND;
|
||||
mocks.createSearchClient.mockReturnValue({ index: vi.fn() });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
it("hydrates Meilisearch hits by ID instead of scanning the full SQL search set", async () => {
|
||||
const articleA = { id: "article_a", title: "A" };
|
||||
const articleB = { id: "article_b", title: "B" };
|
||||
|
||||
mocks.searchWithFallback.mockResolvedValue({
|
||||
hits: [{ document: { id: "article_b" } }, { document: { id: "article_a" } }],
|
||||
estimatedTotalHits: 2,
|
||||
processingTimeMs: 3,
|
||||
});
|
||||
mocks.findKnowledgeBaseArticlesByIds.mockResolvedValue([articleA, articleB]);
|
||||
|
||||
await expect(
|
||||
searchKnowledgeBaseArticles({
|
||||
knowledgeBaseSlug: "docs",
|
||||
language: "de",
|
||||
query: "billing",
|
||||
tenantId: "tenant_1",
|
||||
})
|
||||
).resolves.toEqual([articleB, articleA]);
|
||||
|
||||
expect(mocks.findKnowledgeBaseArticlesByIds).toHaveBeenCalledWith({
|
||||
knowledgeBaseSlug: "docs",
|
||||
language: "de",
|
||||
ids: ["article_b", "article_a"],
|
||||
});
|
||||
expect(mocks.findKnowledgeBaseArticles).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("escapes the language value in the Meilisearch filter to prevent filter injection", async () => {
|
||||
mocks.searchWithFallback.mockResolvedValue({ hits: [], estimatedTotalHits: 0, processingTimeMs: 1 });
|
||||
|
||||
await searchKnowledgeBaseArticles({
|
||||
knowledgeBaseSlug: "docs",
|
||||
// A crafted language attempting to break out of the quoted filter string.
|
||||
language: 'de" OR isPublished = "true',
|
||||
query: "billing",
|
||||
tenantId: "tenant_1",
|
||||
});
|
||||
|
||||
const passedOptions = mocks.searchWithFallback.mock.calls[0]?.[0]?.options;
|
||||
expect(passedOptions.extraFilters).toBe('language = "de\\" OR isPublished = \\"true" AND isPublished = true');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* KB-article public search — hot-path migration to Meilisearch.
|
||||
*
|
||||
* The function exposes the same return shape as the legacy
|
||||
* `findKnowledgeBaseArticles` SQL implementation so the consuming
|
||||
* KnowledgeBaseService.search code path can adopt it without further
|
||||
* refactors. When MEILI_URL is unset (local dev that did not enable
|
||||
* Meilisearch), or SEARCH_BACKEND=sql is forced, we transparently fall
|
||||
* back to the legacy LIKE-based query. The same SQL path also engages
|
||||
* automatically when Meilisearch raises a connectivity error — that is
|
||||
* what `searchWithFallback` is for.
|
||||
*/
|
||||
|
||||
import {
|
||||
createSearchClient,
|
||||
kbArticlesIndex,
|
||||
searchWithFallback,
|
||||
type SearchClient,
|
||||
type SearchFallback,
|
||||
} from "@toolctrl/search";
|
||||
import {
|
||||
findKnowledgeBaseArticlesByIds,
|
||||
findKnowledgeBaseArticles,
|
||||
type KnowledgeBaseArticleWithDetails,
|
||||
} from "../db/kbArticles.db.server";
|
||||
|
||||
let cachedClient: SearchClient | undefined;
|
||||
let cachedUrl: string | undefined;
|
||||
|
||||
// Escape values interpolated into a Meilisearch filter expression so a crafted
|
||||
// `language` (sourced from the optional `($lang)` URL segment) cannot break out
|
||||
// of the quoted string and inject filter operators. Mirrors rowSearch.server.ts.
|
||||
function escapeFilterValue(value: string): string {
|
||||
return value.replace(/\\/gu, "\\\\").replace(/"/gu, '\\"');
|
||||
}
|
||||
|
||||
function getSearchClient(): SearchClient | undefined {
|
||||
const url = process.env.MEILI_URL;
|
||||
if (!url || process.env.SEARCH_BACKEND === "sql") return undefined;
|
||||
if (cachedClient && cachedUrl === url) return cachedClient;
|
||||
cachedClient = createSearchClient({ url, apiKey: process.env.MEILI_API_KEY ?? undefined });
|
||||
cachedUrl = url;
|
||||
return cachedClient;
|
||||
}
|
||||
|
||||
export interface SearchKnowledgeBaseArticlesInput {
|
||||
readonly knowledgeBaseSlug: string;
|
||||
readonly language: string | undefined;
|
||||
readonly query: string;
|
||||
/**
|
||||
* Tenant scope. Required when the Meilisearch path is engaged; the SQL
|
||||
* fallback ignores it because the (knowledgeBaseSlug, language) filter
|
||||
* already isolates results within a KB.
|
||||
*/
|
||||
readonly tenantId: string | undefined;
|
||||
}
|
||||
|
||||
export async function searchKnowledgeBaseArticles(
|
||||
input: SearchKnowledgeBaseArticlesInput
|
||||
): Promise<KnowledgeBaseArticleWithDetails[]> {
|
||||
const sqlFallback: SearchFallback = async () => {
|
||||
const rows = await findKnowledgeBaseArticles({
|
||||
knowledgeBaseSlug: input.knowledgeBaseSlug,
|
||||
language: input.language,
|
||||
query: input.query,
|
||||
});
|
||||
return {
|
||||
hits: rows.map((row) => ({ document: { ...row, id: row.id, tenantId: input.tenantId ?? "" } as Record<string, unknown> & { id: string | number } })),
|
||||
estimatedTotalHits: rows.length,
|
||||
processingTimeMs: 0,
|
||||
};
|
||||
};
|
||||
|
||||
const client = getSearchClient();
|
||||
if (!client || !input.tenantId) {
|
||||
// No Meilisearch backend configured or no tenantId in scope —
|
||||
// the SQL path is the authoritative source.
|
||||
const rows = await findKnowledgeBaseArticles({
|
||||
knowledgeBaseSlug: input.knowledgeBaseSlug,
|
||||
language: input.language,
|
||||
query: input.query,
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
const hits = await searchWithFallback({
|
||||
client,
|
||||
schema: kbArticlesIndex,
|
||||
tenantId: input.tenantId,
|
||||
query: input.query,
|
||||
fallback: sqlFallback,
|
||||
options: input.language
|
||||
? { extraFilters: `language = "${escapeFilterValue(input.language)}" AND isPublished = true` }
|
||||
: { extraFilters: "isPublished = true" },
|
||||
});
|
||||
|
||||
// The Meilisearch hit-stream doesn't carry the full ORM relation graph
|
||||
// the route expects (category/section/related-articles); look the
|
||||
// resolved IDs back up against the SQL store so the consumer keeps
|
||||
// its existing typed return shape.
|
||||
const ids = hits.hits.map((hit) => String(hit.document.id));
|
||||
if (ids.length === 0) return [];
|
||||
const rows = await findKnowledgeBaseArticlesByIds({
|
||||
knowledgeBaseSlug: input.knowledgeBaseSlug,
|
||||
language: input.language,
|
||||
ids,
|
||||
});
|
||||
const byId = new Map(rows.map((row) => [row.id, row]));
|
||||
return ids.map((id) => byId.get(id)).filter((row): row is KnowledgeBaseArticleWithDetails => row !== undefined);
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
import type { KnowledgeBasesTemplateDto } from "../dtos/KnowledgeBasesTemplateDto";
|
||||
import deTranslations from "../../../../public/locales/de/translations.json";
|
||||
import enTranslations from "../../../../public/locales/en/translations.json";
|
||||
|
||||
type SupportedTemplateLanguage = "en" | "de";
|
||||
type TemplateCatalog = typeof enTranslations;
|
||||
type TemplateKnowledgeBase = Omit<KnowledgeBasesTemplateDto["knowledgeBases"][number], "description" | "links" | "title"> & {
|
||||
description: string;
|
||||
links: (Omit<KnowledgeBasesTemplateDto["knowledgeBases"][number]["links"][number], "name"> & { name: string })[];
|
||||
title: string;
|
||||
};
|
||||
type TemplateCategory = Omit<KnowledgeBasesTemplateDto["categories"][number], "description" | "sections" | "title"> & {
|
||||
description: string;
|
||||
sections: (Omit<KnowledgeBasesTemplateDto["categories"][number]["sections"][number], "description" | "title"> & {
|
||||
description: string;
|
||||
title: string;
|
||||
})[];
|
||||
title: string;
|
||||
};
|
||||
type TemplateArticle = Omit<
|
||||
KnowledgeBasesTemplateDto["articles"][number],
|
||||
"contentDraft" | "contentPublished" | "contentPublishedAsText" | "description" | "title"
|
||||
> & {
|
||||
contentDraft: string;
|
||||
contentPublished: string;
|
||||
contentPublishedAsText: string;
|
||||
description: string;
|
||||
title: string;
|
||||
};
|
||||
type TemplateSource = {
|
||||
knowledgeBases: TemplateKnowledgeBase[];
|
||||
categories: TemplateCategory[];
|
||||
articles: TemplateArticle[];
|
||||
};
|
||||
|
||||
const TEMPLATE_CATALOGS: Record<SupportedTemplateLanguage, TemplateCatalog> = {
|
||||
de: deTranslations,
|
||||
en: enTranslations,
|
||||
};
|
||||
|
||||
function templateKey(path: string) {
|
||||
return `knowledgeBase.defaultTemplates.sample.${path}`;
|
||||
}
|
||||
|
||||
function getCatalogValue(catalog: TemplateCatalog, key: string) {
|
||||
return key.split(".").reduce<unknown>((current, segment) => {
|
||||
if (!current || typeof current !== "object" || !(segment in current)) {
|
||||
return undefined;
|
||||
}
|
||||
return (current as Record<string, unknown>)[segment];
|
||||
}, catalog);
|
||||
}
|
||||
|
||||
function translateTemplateValue(language: SupportedTemplateLanguage, key: string) {
|
||||
const value = getCatalogValue(TEMPLATE_CATALOGS[language], key);
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
|
||||
const fallback = getCatalogValue(TEMPLATE_CATALOGS.en, key);
|
||||
return typeof fallback === "string" ? fallback : key;
|
||||
}
|
||||
|
||||
const SAMPLE_SOURCE: TemplateSource = {
|
||||
knowledgeBases: [
|
||||
{
|
||||
basePath: "/",
|
||||
slug: "docs",
|
||||
title: templateKey("knowledgeBases.docs.title"),
|
||||
description: templateKey("knowledgeBases.docs.description"),
|
||||
defaultLanguage: "en",
|
||||
layout: "list",
|
||||
color: 15,
|
||||
enabled: true,
|
||||
languages: ["en"],
|
||||
links: [],
|
||||
logo: "dark",
|
||||
seoImage: "",
|
||||
},
|
||||
{
|
||||
basePath: "/docs",
|
||||
slug: "center",
|
||||
title: templateKey("knowledgeBases.center.title"),
|
||||
description: templateKey("knowledgeBases.center.description"),
|
||||
defaultLanguage: "en",
|
||||
layout: "articles",
|
||||
color: 16,
|
||||
enabled: true,
|
||||
languages: ["de", "en"],
|
||||
links: [
|
||||
{
|
||||
name: templateKey("knowledgeBases.center.links.backToSite"),
|
||||
href: "/",
|
||||
order: 1,
|
||||
},
|
||||
],
|
||||
logo: "dark",
|
||||
seoImage: "",
|
||||
},
|
||||
],
|
||||
categories: [
|
||||
{
|
||||
knowledgeBaseSlug: "docs",
|
||||
slug: "introduction",
|
||||
order: 1,
|
||||
title: templateKey("categories.introduction.title"),
|
||||
description: templateKey("categories.introduction.description"),
|
||||
icon: "",
|
||||
language: "en",
|
||||
seoImage: "",
|
||||
sections: [],
|
||||
},
|
||||
{
|
||||
knowledgeBaseSlug: "center",
|
||||
slug: "getting-started",
|
||||
order: 1,
|
||||
title: templateKey("categories.gettingStarted.title"),
|
||||
description: templateKey("categories.gettingStarted.description"),
|
||||
icon: '<svg xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" width="50" height="50" viewBox="0 0 50 50" className={className} fill="currentColor"> <path d="M47.16,21.221l-5.91-0.966c-0.346-1.186-0.819-2.326-1.411-3.405l3.45-4.917c0.279-0.397,0.231-0.938-0.112-1.282 l-3.889-3.887c-0.347-0.346-0.893-0.391-1.291-0.104l-4.843,3.481c-1.089-0.602-2.239-1.08-3.432-1.427l-1.031-5.886 C28.607,2.35,28.192,2,27.706,2h-5.5c-0.49,0-0.908,0.355-0.987,0.839l-0.956,5.854c-1.2,0.345-2.352,0.818-3.437,1.412l-4.83-3.45 c-0.399-0.285-0.942-0.239-1.289,0.106L6.82,10.648c-0.343,0.343-0.391,0.883-0.112,1.28l3.399,4.863 c-0.605,1.095-1.087,2.254-1.438,3.46l-5.831,0.971c-0.482,0.08-0.836,0.498-0.836,0.986v5.5c0,0.485,0.348,0.9,0.825,0.985 l5.831,1.034c0.349,1.203,0.831,2.362,1.438,3.46l-3.441,4.813c-0.284,0.397-0.239,0.942,0.106,1.289l3.888,3.891 c0.343,0.343,0.884,0.391,1.281,0.112l4.87-3.411c1.093,0.601,2.248,1.078,3.445,1.424l0.976,5.861C21.3,47.647,21.717,48,22.206,48 h5.5c0.485,0,0.9-0.348,0.984-0.825l1.045-5.89c1.199-0.353,2.348-0.833,3.43-1.435l4.905,3.441 c0.398,0.281,0.938,0.232,1.282-0.111l3.888-3.891c0.346-0.347,0.391-0.894,0.104-1.292l-3.498-4.857 c0.593-1.08,1.064-2.222,1.407-3.408l5.918-1.039c0.479-0.084,0.827-0.5,0.827-0.985v-5.5C47.999,21.718,47.644,21.3,47.16,21.221z M25,32c-3.866,0-7-3.134-7-7c0-3.866,3.134-7,7-7s7,3.134,7,7C32,28.866,28.866,32,25,32z"></path> </svg>',
|
||||
language: "en",
|
||||
seoImage: "",
|
||||
sections: [],
|
||||
},
|
||||
{
|
||||
knowledgeBaseSlug: "center",
|
||||
slug: "kategorie-1",
|
||||
order: 1,
|
||||
title: templateKey("categories.gettingStartedDe.title"),
|
||||
description: templateKey("categories.gettingStartedDe.description"),
|
||||
icon: '<svg xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" width="50" height="50" viewBox="0 0 50 50" className={className} fill="currentColor"> <path d="M47.16,21.221l-5.91-0.966c-0.346-1.186-0.819-2.326-1.411-3.405l3.45-4.917c0.279-0.397,0.231-0.938-0.112-1.282 l-3.889-3.887c-0.347-0.346-0.893-0.391-1.291-0.104l-4.843,3.481c-1.089-0.602-2.239-1.08-3.432-1.427l-1.031-5.886 C28.607,2.35,28.192,2,27.706,2h-5.5c-0.49,0-0.908,0.355-0.987,0.839l-0.956,5.854c-1.2,0.345-2.352,0.818-3.437,1.412l-4.83-3.45 c-0.399-0.285-0.942-0.239-1.289,0.106L6.82,10.648c-0.343,0.343-0.391,0.883-0.112,1.28l3.399,4.863 c-0.605,1.095-1.087,2.254-1.438,3.46l-5.831,0.971c-0.482,0.08-0.836,0.498-0.836,0.986v5.5c0,0.485,0.348,0.9,0.825,0.985 l5.831,1.034c0.349,1.203,0.831,2.362,1.438,3.46l-3.441,4.813c-0.284,0.397-0.239,0.942,0.106,1.289l3.888,3.891 c0.343,0.343,0.884,0.391,1.281,0.112l4.87-3.411c1.093,0.601,2.248,1.078,3.445,1.424l0.976,5.861C21.3,47.647,21.717,48,22.206,48 h5.5c0.485,0,0.9-0.348,0.984-0.825l1.045-5.89c1.199-0.353,2.348-0.833,3.43-1.435l4.905,3.441 c0.398,0.281,0.938,0.232,1.282-0.111l3.888-3.891c0.346-0.347,0.391-0.894,0.104-1.292l-3.498-4.857 c0.593-1.08,1.064-2.222,1.407-3.408l5.918-1.039c0.479-0.084,0.827-0.5,0.827-0.985v-5.5C47.999,21.718,47.644,21.3,47.16,21.221z M25,32c-3.866,0-7-3.134-7-7c0-3.866,3.134-7,7-7s7,3.134,7,7C32,28.866,28.866,32,25,32z"></path> </svg>',
|
||||
language: "de",
|
||||
seoImage: "",
|
||||
sections: [
|
||||
{
|
||||
order: 1,
|
||||
title: templateKey("categories.gettingStartedDe.sections.basics.title"),
|
||||
description: templateKey("categories.gettingStartedDe.sections.basics.description"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
knowledgeBaseSlug: "center",
|
||||
slug: "kategorie-2",
|
||||
order: 2,
|
||||
title: templateKey("categories.accountManagementDe.title"),
|
||||
description: templateKey("categories.accountManagementDe.description"),
|
||||
icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"> <path fillRule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clipRule="evenodd" /> </svg>',
|
||||
language: "de",
|
||||
seoImage: "",
|
||||
sections: [],
|
||||
},
|
||||
{
|
||||
knowledgeBaseSlug: "center",
|
||||
slug: "account-management",
|
||||
order: 2,
|
||||
title: templateKey("categories.accountManagement.title"),
|
||||
description: templateKey("categories.accountManagement.description"),
|
||||
icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"> <path fillRule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clipRule="evenodd" /> </svg>',
|
||||
language: "en",
|
||||
seoImage: "",
|
||||
sections: [],
|
||||
},
|
||||
],
|
||||
articles: [
|
||||
{
|
||||
knowledgeBaseSlug: "docs",
|
||||
categorySlug: "introduction",
|
||||
categorySectionOrder: null,
|
||||
slug: "example-site-knowledge-base",
|
||||
title: templateKey("articles.exampleSiteKnowledgeBase.title"),
|
||||
description: templateKey("articles.exampleSiteKnowledgeBase.description"),
|
||||
order: 0,
|
||||
contentDraft: templateKey("articles.exampleSiteKnowledgeBase.content.markup"),
|
||||
contentPublished: templateKey("articles.exampleSiteKnowledgeBase.content.markup"),
|
||||
contentPublishedAsText: templateKey("articles.exampleSiteKnowledgeBase.content.text"),
|
||||
contentType: "wysiwyg",
|
||||
language: "en",
|
||||
featuredOrder: 2,
|
||||
seoImage: "",
|
||||
publishedAt: "2023-07-01T23:42:31.371Z",
|
||||
createdByUserEmail: null,
|
||||
relatedArticles: [],
|
||||
},
|
||||
{
|
||||
knowledgeBaseSlug: "docs",
|
||||
categorySlug: "introduction",
|
||||
categorySectionOrder: null,
|
||||
slug: "publish-product-documentation",
|
||||
title: templateKey("articles.publishProductDocumentation.title"),
|
||||
description: templateKey("articles.publishProductDocumentation.description"),
|
||||
order: 0,
|
||||
contentDraft: templateKey("articles.publishProductDocumentation.content.markup"),
|
||||
contentPublished: templateKey("articles.publishProductDocumentation.content.markup"),
|
||||
contentPublishedAsText: templateKey("articles.publishProductDocumentation.content.text"),
|
||||
contentType: "wysiwyg",
|
||||
language: "en",
|
||||
featuredOrder: 1,
|
||||
seoImage: "",
|
||||
publishedAt: "2023-07-01T21:49:40.834Z",
|
||||
createdByUserEmail: null,
|
||||
relatedArticles: [],
|
||||
},
|
||||
{
|
||||
knowledgeBaseSlug: "center",
|
||||
categorySlug: "kategorie-1",
|
||||
categorySectionOrder: 1,
|
||||
slug: "beispiel-2",
|
||||
title: templateKey("articles.structureKnowledgeBaseDe.title"),
|
||||
description: templateKey("articles.structureKnowledgeBaseDe.description"),
|
||||
order: 0,
|
||||
contentDraft: templateKey("articles.structureKnowledgeBaseDe.content.markup"),
|
||||
contentPublished: templateKey("articles.structureKnowledgeBaseDe.content.markup"),
|
||||
contentPublishedAsText: templateKey("articles.structureKnowledgeBaseDe.content.text"),
|
||||
contentType: "wysiwyg",
|
||||
language: "de",
|
||||
featuredOrder: 3,
|
||||
seoImage: "",
|
||||
publishedAt: "2023-07-01T21:39:51.857Z",
|
||||
createdByUserEmail: null,
|
||||
relatedArticles: [],
|
||||
},
|
||||
{
|
||||
knowledgeBaseSlug: "center",
|
||||
categorySlug: "getting-started",
|
||||
categorySectionOrder: null,
|
||||
slug: "structure-your-help-center",
|
||||
title: templateKey("articles.structureYourHelpCenter.title"),
|
||||
description: templateKey("articles.structureYourHelpCenter.description"),
|
||||
order: 0,
|
||||
contentDraft: templateKey("articles.structureYourHelpCenter.content.markup"),
|
||||
contentPublished: templateKey("articles.structureYourHelpCenter.content.markup"),
|
||||
contentPublishedAsText: templateKey("articles.structureYourHelpCenter.content.text"),
|
||||
contentType: "wysiwyg",
|
||||
language: "en",
|
||||
featuredOrder: 2,
|
||||
seoImage: "",
|
||||
publishedAt: "2023-07-01T23:35:32.524Z",
|
||||
createdByUserEmail: null,
|
||||
relatedArticles: [
|
||||
{
|
||||
slug: "welcome-to-the-help-center",
|
||||
},
|
||||
{
|
||||
slug: "manage-team-access",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
knowledgeBaseSlug: "center",
|
||||
categorySlug: "getting-started",
|
||||
categorySectionOrder: null,
|
||||
slug: "welcome-to-the-help-center",
|
||||
title: templateKey("articles.welcomeToTheHelpCenter.title"),
|
||||
description: templateKey("articles.welcomeToTheHelpCenter.description"),
|
||||
order: 0,
|
||||
contentDraft: templateKey("articles.welcomeToTheHelpCenter.content.markup"),
|
||||
contentPublished: templateKey("articles.welcomeToTheHelpCenter.content.markup"),
|
||||
contentPublishedAsText: templateKey("articles.welcomeToTheHelpCenter.content.text"),
|
||||
contentType: "wysiwyg",
|
||||
language: "en",
|
||||
featuredOrder: 1,
|
||||
seoImage: "",
|
||||
publishedAt: "2023-07-01T21:39:38.696Z",
|
||||
createdByUserEmail: null,
|
||||
relatedArticles: [],
|
||||
},
|
||||
{
|
||||
knowledgeBaseSlug: "center",
|
||||
categorySlug: "kategorie-1",
|
||||
categorySectionOrder: null,
|
||||
slug: "beispiel-1",
|
||||
title: templateKey("articles.welcomeToKnowledgeBaseDe.title"),
|
||||
description: templateKey("articles.welcomeToKnowledgeBaseDe.description"),
|
||||
order: 0,
|
||||
contentDraft: templateKey("articles.welcomeToKnowledgeBaseDe.content.markup"),
|
||||
contentPublished: templateKey("articles.welcomeToKnowledgeBaseDe.content.markup"),
|
||||
contentPublishedAsText: templateKey("articles.welcomeToKnowledgeBaseDe.content.text"),
|
||||
contentType: "wysiwyg",
|
||||
language: "de",
|
||||
featuredOrder: 3,
|
||||
seoImage: "",
|
||||
publishedAt: "2023-07-01T21:39:49.570Z",
|
||||
createdByUserEmail: null,
|
||||
relatedArticles: [],
|
||||
},
|
||||
{
|
||||
knowledgeBaseSlug: "center",
|
||||
categorySlug: "kategorie-2",
|
||||
categorySectionOrder: null,
|
||||
slug: "beispiel-3",
|
||||
title: templateKey("articles.manageTeamAccessDe.title"),
|
||||
description: templateKey("articles.manageTeamAccessDe.description"),
|
||||
order: 0,
|
||||
contentDraft: templateKey("articles.manageTeamAccessDe.content.markup"),
|
||||
contentPublished: templateKey("articles.manageTeamAccessDe.content.markup"),
|
||||
contentPublishedAsText: templateKey("articles.manageTeamAccessDe.content.text"),
|
||||
contentType: "wysiwyg",
|
||||
language: "de",
|
||||
featuredOrder: null,
|
||||
seoImage: "",
|
||||
publishedAt: "2023-07-01T21:39:47.550Z",
|
||||
createdByUserEmail: null,
|
||||
relatedArticles: [],
|
||||
},
|
||||
{
|
||||
knowledgeBaseSlug: "center",
|
||||
categorySlug: "account-management",
|
||||
categorySectionOrder: null,
|
||||
slug: "manage-team-access",
|
||||
title: templateKey("articles.manageTeamAccess.title"),
|
||||
description: templateKey("articles.manageTeamAccess.description"),
|
||||
order: 0,
|
||||
contentDraft: templateKey("articles.manageTeamAccess.content.markup"),
|
||||
contentPublished: templateKey("articles.manageTeamAccess.content.markup"),
|
||||
contentPublishedAsText: templateKey("articles.manageTeamAccess.content.text"),
|
||||
contentType: "wysiwyg",
|
||||
language: "en",
|
||||
featuredOrder: null,
|
||||
seoImage: "",
|
||||
publishedAt: "2023-07-01T21:39:36.515Z",
|
||||
createdByUserEmail: null,
|
||||
relatedArticles: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function buildSample(language: SupportedTemplateLanguage): KnowledgeBasesTemplateDto {
|
||||
const knowledgeBases = SAMPLE_SOURCE.knowledgeBases
|
||||
.filter((kb) => kb.languages.includes(language))
|
||||
.map((kb) => ({
|
||||
...kb,
|
||||
title: translateTemplateValue(language, kb.title),
|
||||
description: translateTemplateValue(language, kb.description),
|
||||
defaultLanguage: language,
|
||||
languages: [language],
|
||||
links: kb.links.map((link) => ({
|
||||
...link,
|
||||
name: translateTemplateValue(language, link.name),
|
||||
})),
|
||||
}));
|
||||
const knowledgeBaseSlugs = new Set(knowledgeBases.map((kb) => kb.slug));
|
||||
const categories = SAMPLE_SOURCE.categories
|
||||
.filter((category) => category.language === language && knowledgeBaseSlugs.has(category.knowledgeBaseSlug))
|
||||
.map((category) => ({
|
||||
...category,
|
||||
title: translateTemplateValue(language, category.title),
|
||||
description: translateTemplateValue(language, category.description),
|
||||
sections: category.sections.map((section) => ({
|
||||
...section,
|
||||
title: translateTemplateValue(language, section.title),
|
||||
description: translateTemplateValue(language, section.description),
|
||||
})),
|
||||
}));
|
||||
const categorySlugs = new Set(categories.map((category) => `${category.knowledgeBaseSlug}:${category.slug}`));
|
||||
const articles = SAMPLE_SOURCE.articles
|
||||
.filter(
|
||||
(article) =>
|
||||
article.language === language &&
|
||||
knowledgeBaseSlugs.has(article.knowledgeBaseSlug) &&
|
||||
(!article.categorySlug || categorySlugs.has(`${article.knowledgeBaseSlug}:${article.categorySlug}`))
|
||||
)
|
||||
.map((article) => ({
|
||||
...article,
|
||||
title: translateTemplateValue(language, article.title),
|
||||
description: translateTemplateValue(language, article.description),
|
||||
contentDraft: translateTemplateValue(language, article.contentDraft),
|
||||
contentPublished: translateTemplateValue(language, article.contentPublished),
|
||||
contentPublishedAsText: translateTemplateValue(language, article.contentPublishedAsText),
|
||||
}));
|
||||
|
||||
return {
|
||||
knowledgeBases,
|
||||
categories,
|
||||
articles,
|
||||
};
|
||||
}
|
||||
|
||||
const SAMPLE_EN = buildSample("en");
|
||||
const SAMPLE_DE = buildSample("de");
|
||||
const SAMPLE = SAMPLE_EN;
|
||||
|
||||
export default {
|
||||
SAMPLE,
|
||||
SAMPLE_DE,
|
||||
SAMPLE_EN,
|
||||
};
|
||||
@@ -0,0 +1,162 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import UrlUtils from "~/utils/app/UrlUtils";
|
||||
import { KnowledgeBaseArticleWithDetails } from "../db/kbArticles.db.server";
|
||||
import { KbCategorySectionDto } from "../dtos/KbCategorySectionDto";
|
||||
import { KnowledgeBaseDto } from "../dtos/KnowledgeBaseDto";
|
||||
import { KbCategoryDto } from "../dtos/KbCategoryDto";
|
||||
import { KbArticleDto } from "../dtos/KbArticleDto";
|
||||
|
||||
const defaultLanguage = "de";
|
||||
const supportedLanguages: { name: string; value: string; labelKey: string }[] = [
|
||||
{ name: "Deutsch", value: "de", labelKey: "shared.locales.de" },
|
||||
{ name: "English", value: "en", labelKey: "shared.locales.en" },
|
||||
];
|
||||
|
||||
function getLanguageName(language: string, t?: TFunction) {
|
||||
const supportedLanguage = supportedLanguages.find((l) => l.value === language);
|
||||
if (!supportedLanguage) {
|
||||
return "";
|
||||
}
|
||||
return t ? String(t(supportedLanguage.labelKey, { defaultValue: supportedLanguage.name })) : supportedLanguage.name;
|
||||
}
|
||||
|
||||
function getSupportedLanguageOptions(t: TFunction): { name: string; value: string }[] {
|
||||
return supportedLanguages.map((language) => ({
|
||||
name: getLanguageName(language.value, t),
|
||||
value: language.value,
|
||||
}));
|
||||
}
|
||||
|
||||
function getAvailableArticleSlug({ allArticles, initialSlug }: { allArticles: KnowledgeBaseArticleWithDetails[]; initialSlug: string }) {
|
||||
let number = 1;
|
||||
let slug = "";
|
||||
|
||||
const findExistingSlug = (slug: string) => {
|
||||
return allArticles.find((p) => p.slug === slug);
|
||||
};
|
||||
|
||||
do {
|
||||
slug = `${initialSlug}-${number}`;
|
||||
const existingWithSlug = findExistingSlug(slug);
|
||||
if (!existingWithSlug) {
|
||||
break;
|
||||
}
|
||||
// if (number > 10) {
|
||||
// throw Error("Too many duplicates");
|
||||
// }
|
||||
number++;
|
||||
} while (true);
|
||||
|
||||
let maxOrder = 0;
|
||||
let firstOrder = 0;
|
||||
if (allArticles.length > 0) {
|
||||
maxOrder = Math.max(...allArticles.map((p) => p.order));
|
||||
firstOrder = Math.min(...allArticles.map((p) => p.order));
|
||||
}
|
||||
return {
|
||||
slug,
|
||||
maxOrder,
|
||||
firstOrder,
|
||||
number,
|
||||
};
|
||||
}
|
||||
|
||||
function getKbUrl({ kb, params }: { kb: { basePath: string; slug: string }; params: { lang?: string } }) {
|
||||
let url = UrlUtils.join(kb.basePath, kb.slug);
|
||||
const foundLang = supportedLanguages.find((l) => l.value === params.lang);
|
||||
if (params.lang && foundLang) {
|
||||
return `${url}/${params.lang}`;
|
||||
}
|
||||
return `${url}`;
|
||||
}
|
||||
|
||||
function getArticleUrl({ kb, article, params }: { kb: { slug: string; basePath: string }; article: { slug: string }; params: { lang?: string } }) {
|
||||
let kbUrl = getKbUrl({ kb, params });
|
||||
return `${kbUrl}/articles/${article.slug}`;
|
||||
}
|
||||
|
||||
function getCategoryUrl({ kb, category, params }: { kb: { slug: string; basePath: string }; category: { slug: string }; params: { lang?: string } }) {
|
||||
let kbUrl = getKbUrl({ kb, params });
|
||||
return `${kbUrl}/categories/${category.slug}`;
|
||||
}
|
||||
|
||||
function getCategoryArticlesBySections({ kb, category }: { kb: KnowledgeBaseDto; category: KbCategoryDto }) {
|
||||
let sections: {
|
||||
section: KbCategorySectionDto | null;
|
||||
articles: {
|
||||
order: number;
|
||||
title: string;
|
||||
description: string;
|
||||
href: string;
|
||||
section: string | null;
|
||||
sectionId: string | null;
|
||||
}[];
|
||||
}[] = [];
|
||||
category.articles.forEach((article) => {
|
||||
const section = category.sections.find((item) => item.id === article.sectionId) ?? null;
|
||||
const sectionIndex = sections.findIndex((item) => item.section?.id === section?.id);
|
||||
if (sectionIndex === -1) {
|
||||
sections.push({
|
||||
section,
|
||||
articles: [
|
||||
{
|
||||
...article,
|
||||
section: section?.title ?? null,
|
||||
},
|
||||
],
|
||||
});
|
||||
} else {
|
||||
sections[sectionIndex].articles.push({
|
||||
...article,
|
||||
section: section?.title ?? null,
|
||||
});
|
||||
}
|
||||
});
|
||||
sections.forEach((item) => {
|
||||
item.articles = item.articles.sort((a, b) => {
|
||||
if (a.order && b.order) {
|
||||
return a.order - b.order;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
});
|
||||
sections = sections.sort((a, b) => {
|
||||
if (a.section?.order && b.section?.order) {
|
||||
return a.section.order - b.section.order;
|
||||
}
|
||||
return -1;
|
||||
});
|
||||
return sections;
|
||||
}
|
||||
|
||||
function fixDarkMode({ article }: { article: KbArticleDto }) {
|
||||
const replaces = {
|
||||
"rounded-sm bg-stone-100 p-5 font-mono font-medium text-stone-800": "rounded-sm bg-secondary text-secondary-foreground p-5 font-mono font-medium",
|
||||
"rounded-sm bg-secondary p-5 font-mono font-medium": "rounded-sm bg-secondary text-secondary-foreground p-5 font-mono font-medium",
|
||||
"rounded-md bg-secondary px-1.5 py-1 font-mono font-medium": "rounded-md bg-secondary text-secondary-foreground px-1.5 py-1 font-mono font-medium",
|
||||
"rounded-md bg-stone-200 px-1.5 py-1 font-mono font-medium text-black":
|
||||
"rounded-md bg-secondary px-1.5 py-1 font-mono font-medium text-secondary-foreground",
|
||||
"text-stone-600 underline underline-offset-[3px] hover:text-stone-700 transition-colors cursor-pointer text-stone-600 underline underline-offset-[3px] hover:text-stone-700 transition-colors cursor-pointer":
|
||||
"text-muted-foreground underline underline-offset-[3px] hover:text-foreground transition-colors cursor-pointer",
|
||||
"underline underline-offset-[3px] hover:text-foreground transition-colors cursor-pointer":
|
||||
"text-muted-foreground underline underline-offset-[3px] hover:text-foreground transition-colors cursor-pointer",
|
||||
};
|
||||
if (article.contentPublished) {
|
||||
for (const [key, value] of Object.entries(replaces)) {
|
||||
article.contentPublished = article.contentPublished.replaceAll(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
defaultLanguage,
|
||||
supportedLanguages,
|
||||
getSupportedLanguageOptions,
|
||||
getLanguageName,
|
||||
getAvailableArticleSlug,
|
||||
getKbUrl,
|
||||
getArticleUrl,
|
||||
getCategoryUrl,
|
||||
getCategoryArticlesBySections,
|
||||
fixDarkMode,
|
||||
};
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import DefaultKbsTemplate from "./DefaultKbsTemplate";
|
||||
|
||||
const sourcePath = path.resolve(process.cwd(), "apps/web/app/modules/knowledgeBase/utils/DefaultKbsTemplate.ts");
|
||||
const enTranslationsPath = path.resolve(process.cwd(), "apps/web/public/locales/en/translations.json");
|
||||
const deTranslationsPath = path.resolve(process.cwd(), "apps/web/public/locales/de/translations.json");
|
||||
|
||||
function readTranslations(localePath: string) {
|
||||
return JSON.parse(fs.readFileSync(localePath, "utf8")) as {
|
||||
knowledgeBase: {
|
||||
defaultTemplates: {
|
||||
sample: {
|
||||
knowledgeBases: {
|
||||
docs: { description: string; title: string };
|
||||
center: { description: string; links: { backToSite: string }; title: string };
|
||||
};
|
||||
articles: {
|
||||
exampleSiteKnowledgeBase: { description: string; content: { text: string } };
|
||||
publishProductDocumentation: { description: string; content: { text: string } };
|
||||
};
|
||||
categories: {
|
||||
introduction: { description: string };
|
||||
kategorie1: { sections: { basics: { description: string } } };
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
describe("DefaultKbsTemplate content", () => {
|
||||
it("keeps seeded knowledge-base copy free from placeholder junk and misspelled API names", () => {
|
||||
const source = fs.readFileSync(sourcePath, "utf8");
|
||||
|
||||
expect(source).toContain("knowledgeBase.defaultTemplates.sample");
|
||||
expect(source).not.toContain("knowlegeBase");
|
||||
expect(source).not.toContain("asdf");
|
||||
expect(source).not.toContain("Sample description");
|
||||
expect(source).not.toContain("Category 1");
|
||||
expect(source).not.toContain("Introduction category description sample");
|
||||
expect(source).not.toContain("Example Site - Knowledge Base");
|
||||
expect(source).not.toContain("loom.com");
|
||||
expect(source).not.toContain("demo@toolctrl.local");
|
||||
expect(source).not.toContain("Dynamic vs Static Knowledge Bases");
|
||||
expect(source).not.toContain("apps/web/app/routes/docs/($lang).tsx");
|
||||
expect(source).not.toContain("related articles to guide readers");
|
||||
});
|
||||
|
||||
it("stores semantic locale keys for default knowledge-base seed copy", () => {
|
||||
const source = fs.readFileSync(sourcePath, "utf8");
|
||||
const en = readTranslations(enTranslationsPath);
|
||||
const de = readTranslations(deTranslationsPath);
|
||||
|
||||
expect(source).toContain('templateKey("knowledgeBases.docs.title")');
|
||||
expect(source).toContain('templateKey("articles.publishProductDocumentation.content.text")');
|
||||
expect(source).not.toContain("ui.autogenerated");
|
||||
|
||||
expect(en.knowledgeBase.defaultTemplates.sample.knowledgeBases.docs.title).toBe("Docs");
|
||||
expect(en.knowledgeBase.defaultTemplates.sample.articles.publishProductDocumentation.content.text).toContain("related articles");
|
||||
expect(de.knowledgeBase.defaultTemplates.sample.knowledgeBases.center.links.backToSite).toBe("Zur Website");
|
||||
expect(de.knowledgeBase.defaultTemplates.sample.articles.publishProductDocumentation.content.text).toContain("verwandte Artikel");
|
||||
});
|
||||
|
||||
it("exposes locale-scoped admin import samples instead of mixed public chrome", () => {
|
||||
for (const [template, language] of [
|
||||
[DefaultKbsTemplate.SAMPLE_EN, "en"],
|
||||
[DefaultKbsTemplate.SAMPLE_DE, "de"],
|
||||
] as const) {
|
||||
expect(Object.keys(template).sort()).toEqual(["articles", "categories", "knowledgeBases"]);
|
||||
expect(template.knowledgeBases.every((kb) => kb.defaultLanguage === language)).toBe(true);
|
||||
expect(template.knowledgeBases.every((kb) => kb.languages.length === 1 && kb.languages[0] === language)).toBe(true);
|
||||
expect(template.categories.every((category) => category.language === language)).toBe(true);
|
||||
expect(template.articles.every((article) => article.language === language)).toBe(true);
|
||||
}
|
||||
|
||||
expect(DefaultKbsTemplate.SAMPLE_DE.knowledgeBases.map((kb) => kb.title)).toContain("Hilfe-Center");
|
||||
expect(JSON.stringify(DefaultKbsTemplate.SAMPLE_DE)).not.toContain("Help Center");
|
||||
expect(JSON.stringify(DefaultKbsTemplate.SAMPLE_DE)).not.toContain("Back to site");
|
||||
});
|
||||
|
||||
it("keeps German knowledge-base copy generic while preserving the Hilfe-Center product title", () => {
|
||||
const deSample = DefaultKbsTemplate.SAMPLE_DE;
|
||||
const deCopy = JSON.stringify(deSample);
|
||||
|
||||
expect(deSample.knowledgeBases.find((kb) => kb.slug === "center")?.title).toBe("Hilfe-Center");
|
||||
expect(deSample.categories.find((category) => category.slug === "kategorie-1")?.description).toBe(
|
||||
"Grundlagen für die Einrichtung und Nutzung der Wissensdatenbank."
|
||||
);
|
||||
expect(deSample.articles.find((article) => article.slug === "beispiel-2")?.title).toBe("Wissensdatenbank strukturieren");
|
||||
expect(deSample.articles.find((article) => article.slug === "beispiel-1")?.contentPublishedAsText).toContain("Diese Wissensdatenbank bündelt");
|
||||
expect(deCopy).not.toContain("Nutzung des Hilfe-Centers");
|
||||
expect(deCopy).not.toContain("Dieses Hilfe-Center bündelt");
|
||||
});
|
||||
|
||||
it("keeps default category and article metadata useful for imported starter content", () => {
|
||||
for (const template of [DefaultKbsTemplate.SAMPLE_EN, DefaultKbsTemplate.SAMPLE_DE]) {
|
||||
for (const knowledgeBase of template.knowledgeBases) {
|
||||
expect(knowledgeBase.title.trim()).not.toHaveLength(0);
|
||||
expect(knowledgeBase.description.trim()).not.toHaveLength(0);
|
||||
}
|
||||
for (const category of template.categories) {
|
||||
expect(category.title.trim()).not.toHaveLength(0);
|
||||
expect(category.description.trim()).not.toHaveLength(0);
|
||||
for (const section of category.sections) {
|
||||
expect(section.title.trim()).not.toHaveLength(0);
|
||||
expect(section.description.trim()).not.toHaveLength(0);
|
||||
}
|
||||
}
|
||||
for (const article of template.articles) {
|
||||
expect(article.title.trim()).not.toHaveLength(0);
|
||||
expect(article.description.trim()).not.toHaveLength(0);
|
||||
}
|
||||
}
|
||||
|
||||
expect(DefaultKbsTemplate.SAMPLE_EN.knowledgeBases.find((kb) => kb.slug === "docs")?.description).toBe(
|
||||
"Public product documentation and starter guides for your team."
|
||||
);
|
||||
expect(DefaultKbsTemplate.SAMPLE_EN.knowledgeBases.find((kb) => kb.slug === "center")?.description).toBe(
|
||||
"Customer-facing help articles, setup guidance, and account support."
|
||||
);
|
||||
expect(
|
||||
DefaultKbsTemplate.SAMPLE_DE.categories.find((category) => category.slug === "kategorie-1")?.sections.find((section) => section.order === 1)
|
||||
?.description
|
||||
).toBe("Wichtige Einrichtungshinweise, um hilfreiche Startinhalte zu strukturieren.");
|
||||
|
||||
expect(DefaultKbsTemplate.SAMPLE_EN.categories.find((category) => category.slug === "introduction")?.description).toBe(
|
||||
"Start here for an overview of the documentation structure."
|
||||
);
|
||||
const starterArticle = DefaultKbsTemplate.SAMPLE_EN.articles.find((article) => article.slug === "example-site-knowledge-base");
|
||||
expect(starterArticle?.description).toBe("A short starter article for the public docs knowledge base.");
|
||||
expect(starterArticle?.contentPublishedAsText).toContain("neutral starting point");
|
||||
expect(starterArticle?.createdByUserEmail).toBeNull();
|
||||
|
||||
const publishingArticle = DefaultKbsTemplate.SAMPLE_EN.articles.find((article) => article.slug === "publish-product-documentation");
|
||||
expect(publishingArticle?.description).toBe("A practical checklist for turning draft articles into useful public documentation.");
|
||||
expect(publishingArticle?.contentPublishedAsText).toContain("answers a real user question");
|
||||
expect(publishingArticle?.contentPublishedAsText).not.toContain("route files");
|
||||
});
|
||||
|
||||
it("keeps imported sample articles self-contained and attributed to the importing admin", () => {
|
||||
for (const template of [DefaultKbsTemplate.SAMPLE_EN, DefaultKbsTemplate.SAMPLE_DE]) {
|
||||
const serialized = JSON.stringify(template);
|
||||
expect(serialized).not.toContain("loom.com");
|
||||
expect(serialized).not.toContain("demo@toolctrl.local");
|
||||
expect(template.articles.every((article) => article.createdByUserEmail === null)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user