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,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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user