Преглед изворни кода

WIP: impl new TemplateModal

Yuki Takei пре 2 година
родитељ
комит
9ade943278

+ 50 - 42
apps/app/src/components/TemplateModal/TemplateModal.tsx

@@ -28,6 +28,13 @@ import { useFormatter } from './use-formatter';
 const logger = loggerFactory('growi:components:TemplateModal');
 
 
+function constructTemplateId(templateSummary: TemplateSummary): string {
+  const defaultTemplate = templateSummary.default;
+
+  return `${defaultTemplate.namespace ?? ''}_${defaultTemplate.id}`;
+}
+
+
 type TemplateRadioButtonProps = {
   templateSummary: TemplateSummary,
   onChange: (selectedTemplate: TemplateSummary) => void,
@@ -35,11 +42,11 @@ type TemplateRadioButtonProps = {
   isSelected?: boolean,
 }
 
-const TemplateRadioButton = ({
+const TemplateListGroupItem = ({
   templateSummary, onChange, usersDefaultLang, isSelected,
 }: TemplateRadioButtonProps): JSX.Element => {
-  const templateId = templateSummary.default.id;
-  const radioButtonId = `rb-${templateId}`;
+  const templateId = constructTemplateId(templateSummary);
+  const locales = new Set(Object.values(templateSummary).map(s => s.locale));
 
   const template = usersDefaultLang != null && usersDefaultLang in templateSummary
     ? templateSummary[usersDefaultLang]
@@ -48,18 +55,13 @@ const TemplateRadioButton = ({
   assert(template.isValid);
 
   return (
-    <div key={templateId} className="custom-control custom-radio mb-2">
-      <input
-        id={radioButtonId}
-        type="radio"
-        className="custom-control-input"
-        checked={isSelected}
-        onChange={() => onChange(templateSummary)}
-      />
-      <label className="custom-control-label" htmlFor={radioButtonId}>
-        {template.title}
-      </label>
-    </div>
+    <a key={templateId} className={`list-group-item list-group-item-action ${isSelected ? 'active' : ''}`} aria-current="true">
+      <h4 className="mb-1">{template.title}</h4>
+      <p className="mb-2">{template.desc}</p>
+      { Array.from(locales).map(locale => (
+        <span key={locale} className="badge border rounded-pill text-muted mr-1">{locale}</span>
+      ))}
+    </a>
   );
 };
 
@@ -73,13 +75,13 @@ export const TemplateModal = (): JSX.Element => {
   const { data: rendererOptions } = usePreviewOptions();
   const { data: templateSummaries } = useSWRxTemplates();
 
-  const [selectedTemplateId, setSelectedTemplateId] = useState<string>();
+  const [selectedTemplate, setSelectedTemplate] = useState<TemplateSummary>();
   // const [selectedTemplateLocale, setSelectedTemplateLocale] = useState<string>();
 
   const { format } = useFormatter();
 
   const submitHandler = useCallback((template?: ITemplate) => {
-    if (templateModalStatus == null || selectedTemplateId == null) {
+    if (templateModalStatus == null || selectedTemplate == null) {
       return;
     }
 
@@ -90,11 +92,11 @@ export const TemplateModal = (): JSX.Element => {
 
     // templateModalStatus.onSubmit(format(selectedTemplate));
     close();
-  }, [close, selectedTemplateId, templateModalStatus]);
+  }, [close, selectedTemplate, templateModalStatus]);
 
   useEffect(() => {
     if (!templateModalStatus?.isOpened) {
-      setSelectedTemplateId(undefined);
+      setSelectedTemplate(undefined);
     }
   }, [templateModalStatus?.isOpened]);
 
@@ -103,47 +105,53 @@ export const TemplateModal = (): JSX.Element => {
   }
 
   return (
-    <Modal className="link-edit-modal" isOpen={templateModalStatus.isOpened} toggle={close} size="lg" autoFocus={false}>
+    <Modal className="link-edit-modal" isOpen={templateModalStatus.isOpened} toggle={close} size="xl" autoFocus={false}>
       <ModalHeader tag="h4" toggle={close} className="bg-primary text-light">
         {t('template.modal_label.Select template')}
       </ModalHeader>
 
       <ModalBody className="container">
         <div className="row">
-          <div className="col-12">
-            { Object.entries(templateSummaries).map(([templateId, templateSummary]) => (
-              <TemplateRadioButton
-                key={templateId}
-                templateSummary={templateSummary}
-                usersDefaultLang={personalSettingsInfo?.lang}
-                onChange={() => setSelectedTemplateId(templateId)}
-                isSelected={templateId === selectedTemplateId}
-              />
-            )) }
+          <div className="d-none d-lg-block col-lg-4">
+            <div className="list-group">
+              { templateSummaries.map((templateSummary) => {
+                const templateId = (templateSummary.default.namespace ?? '') + templateSummary.default.id;
+
+                return (
+                  <TemplateListGroupItem
+                    key={templateId}
+                    templateSummary={templateSummary}
+                    usersDefaultLang={personalSettingsInfo?.lang}
+                    onChange={() => setSelectedTemplate(templateSummary)}
+                    isSelected={selectedTemplate != null && constructTemplateId(selectedTemplate) === constructTemplateId(templateSummary)}
+                  />
+                );
+              }) }
+            </div>
           </div>
-        </div>
-
-        <hr />
 
-        <h3>{t('Preview')}</h3>
-        <div className='card'>
-          <div className="card-body" style={{ height: '400px', overflowY: 'auto' }}>
-            { rendererOptions != null && selectedTemplateId != null && (
-              <Preview rendererOptions={rendererOptions} markdown={'' /* format(selectedTemplate) */}/>
-            ) }
+          <div className="col-12 col-lg-8">
+            <h3>{t('Preview')}</h3>
+            <div className='card'>
+              <div className="card-body" style={{ height: '400px', overflowY: 'auto' }}>
+                { rendererOptions != null && selectedTemplate != null && (
+                  <Preview rendererOptions={rendererOptions} markdown={'' /* format(selectedTemplate) */}/>
+                ) }
+              </div>
+            </div>
           </div>
         </div>
 
       </ModalBody>
       <ModalFooter>
-        <button type="button" className="btn btn-sm btn-outline-secondary mx-1" onClick={close}>
+        <button type="button" className="btn btn-outline-secondary mx-1" onClick={close}>
           {t('Cancel')}
         </button>
         <button
           type="submit"
-          className="btn btn-sm btn-primary mx-1"
+          className="btn btn-primary mx-1"
           // onClick={() => submitHandler(selectedTemplate)}
-          disabled={selectedTemplateId == null}>
+          disabled={selectedTemplate == null}>
           {t('commons:Insert')}
         </button>
       </ModalFooter>

+ 4 - 2
apps/app/src/features/templates/server/routes/apiv3/index.ts

@@ -28,9 +28,11 @@ module.exports = (crowi) => {
     const { includeInvalidTemplates } = req.query;
 
     const presetTemplatesRoot = resolveFromRoot('../../node_modules/@growi/preset-templates');
-    const status = await scanAllTemplateStatus(presetTemplatesRoot, { returnsInvalidTemplates: includeInvalidTemplates });
+    const summaries = await scanAllTemplateStatus(presetTemplatesRoot, {
+      returnsInvalidTemplates: includeInvalidTemplates,
+    });
 
-    return res.apiv3(status);
+    return res.apiv3({ summaries });
   });
 
   router.get('/preset-templates/:templateId/:locale', loginRequiredStrictly, validator.get, apiV3FormValidator, async(req, res: ApiV3Response) => {

+ 3 - 3
apps/app/src/features/templates/stores/template.tsx

@@ -1,4 +1,4 @@
-import type { TemplateSummaries } from '@growi/pluginkit/dist/interfaces/v4';
+import type { TemplateSummary } from '@growi/pluginkit/dist/interfaces/v4';
 import useSWR, { type SWRResponse } from 'swr';
 
 import { apiv3Get } from '~/client/util/apiv3-client';
@@ -127,7 +127,7 @@ import { apiv3Get } from '~/client/util/apiv3-client';
 //   },
 // ];
 
-export const useSWRxTemplates = (): SWRResponse<TemplateSummaries, Error> => {
+export const useSWRxTemplates = (): SWRResponse<TemplateSummary[], Error> => {
   // return useSWR(
   //   'templates',
   //   () => [
@@ -140,6 +140,6 @@ export const useSWRxTemplates = (): SWRResponse<TemplateSummaries, Error> => {
   // );
   return useSWR(
     ['/templates'],
-    ([endpoint]) => apiv3Get<TemplateSummaries>(endpoint).then(res => res.data),
+    ([endpoint]) => apiv3Get<{ summaries: TemplateSummary[] }>(endpoint).then(res => res.data.summaries),
   );
 };

+ 6 - 4
packages/pluginkit/src/interfaces/v4/template.ts

@@ -1,4 +1,5 @@
 export type TemplateStatusBasis = {
+  namespace?: string,
   id: string,
   locale: string,
 }
@@ -6,6 +7,7 @@ export type TemplateStatusValid = TemplateStatusBasis & {
   isValid: true,
   isDefault: boolean,
   title: string,
+  desc?: string,
 }
 export type TemplateStatusInvalid = TemplateStatusBasis & {
   isValid: false,
@@ -13,11 +15,11 @@ export type TemplateStatusInvalid = TemplateStatusBasis & {
 }
 export type TemplateStatus = TemplateStatusValid | TemplateStatusInvalid;
 
+export function isTemplateStatusValid(status: TemplateStatus): status is TemplateStatusValid {
+  return status.isValid;
+}
+
 export type TemplateSummary = {
   default: TemplateStatusValid,
   [locale: string]: TemplateStatus,
 }
-
-export type TemplateSummaries = {
-  [templateId: string]: TemplateSummary,
-}

+ 38 - 25
packages/pluginkit/src/server/utils/v4/template.ts

@@ -1,3 +1,4 @@
+import { assert } from 'console';
 import fs from 'fs';
 import path from 'path';
 import { promisify } from 'util';
@@ -7,7 +8,7 @@ import { GrowiPluginType } from '@growi/core/dist/consts';
 import type { GrowiPluginValidationData, GrowiTemplatePluginValidationData } from '~/model';
 import { GrowiPluginValidationError } from '~/model';
 
-import type { TemplateStatus, TemplateSummaries } from '../../../interfaces/v4';
+import { isTemplateStatusValid, type TemplateStatus, type TemplateSummary } from '../../../interfaces/v4';
 
 import { importPackageJson, validatePackageJson } from './package-json';
 
@@ -43,12 +44,11 @@ export const validateTemplatePluginPackageJson = async(projectDirRoot: string):
 };
 
 
-type TemplateDirStatus = {
-  isTemplateExists: boolean,
-  isMetaDataFileExists: boolean,
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  meta?: any,
-}
+type TemplateDirStatus = { isTemplateExists: boolean } &
+  (
+    { isMetaDataFileExists: false } |
+    { isMetaDataFileExists: true, meta: { [key: string]: string } }
+  )
 
 async function getStats(tplDir: string): Promise<TemplateDirStatus> {
   const markdownPath = path.resolve(tplDir, 'template.md');
@@ -62,17 +62,21 @@ async function getStats(tplDir: string): Promise<TemplateDirStatus> {
   const result: TemplateDirStatus = {
     isTemplateExists,
     isMetaDataFileExists,
+    meta: isMetaDataFileExists ? await import(metaDataPath) : undefined,
   };
 
-  if (isMetaDataFileExists) {
-    result.meta = await import(metaDataPath);
-  }
-
   return result;
 }
 
 
-export const scanTemplateStatus = async(projectDirRoot: string, templateId: string, data: GrowiTemplatePluginValidationData): Promise<TemplateStatus[]> => {
+export const scanTemplateStatus = async(
+    projectDirRoot: string,
+    templateId: string,
+    data: GrowiTemplatePluginValidationData,
+    opts?: {
+      namespace?: string,
+    },
+): Promise<TemplateStatus[]> => {
   const status: TemplateStatus[] = [];
 
   const tplRootDirPath = path.resolve(projectDirRoot, 'dist', templateId);
@@ -82,26 +86,34 @@ export const scanTemplateStatus = async(projectDirRoot: string, templateId: stri
     const tplDir = path.resolve(tplRootDirPath, locale);
 
     try {
+      const stats = await getStats(tplDir);
       const {
-        isTemplateExists, isMetaDataFileExists, meta,
-      } = await getStats(tplDir);
+        isTemplateExists, isMetaDataFileExists,
+      } = stats;
 
       if (!isTemplateExists) throw new Error("'template.md does not exist.");
       if (!isMetaDataFileExists) throw new Error("'meta.md does not exist.");
-      if (meta?.title == null) throw new Error("'meta.md does not contain the title.");
+
+      assert(isMetaDataFileExists);
+      const { meta } = stats;
+
+      if (meta?.title) throw new Error("'meta.md does not contain the title.");
 
       const isDefault = !isDefaultPushed;
       status.push({
+        namespace: opts?.namespace,
         id: templateId,
         locale,
         isValid: true,
         isDefault,
         title: meta.title,
+        desc: meta.desc,
       });
       isDefaultPushed = true;
     }
     catch (err) {
       status.push({
+        namespace: opts?.namespace,
         id: templateId,
         locale,
         isValid: false,
@@ -120,35 +132,36 @@ export const scanAllTemplateStatus = async(
     projectDirRoot: string,
     opts?: {
       data?: GrowiTemplatePluginValidationData,
+      namespace?: string,
       returnsInvalidTemplates?: boolean,
     },
-): Promise<TemplateSummaries> => {
+): Promise<TemplateSummary[]> => {
 
   const data = opts?.data ?? await validateTemplatePluginPackageJson(projectDirRoot);
 
-  const summaries = {};
+  const summaries: TemplateSummary[] = [];
 
   const distDirPath = path.resolve(projectDirRoot, 'dist');
   const distDirFiles = fs.readdirSync(distDirPath);
 
   for await (const templateId of distDirFiles) {
-    const status = (await scanTemplateStatus(projectDirRoot, templateId, data))
+    const status = (await scanTemplateStatus(projectDirRoot, templateId, data, { namespace: opts?.namespace }))
       // omit invalid templates if `returnsInvalidTemplates` is true
       .filter(s => (opts?.returnsInvalidTemplates ? true : s.isValid));
 
     // determine default locale
     const defaultTemplateStatus = status.find(s => 'isDefault' in s && s.isDefault);
 
-    if (defaultTemplateStatus == null) {
+    if (defaultTemplateStatus == null || !isTemplateStatusValid(defaultTemplateStatus)) {
       continue;
     }
 
-    summaries[templateId] = Object.assign(
+    summaries.push({
       // for the 'default' key
-      { default: defaultTemplateStatus },
+      default: defaultTemplateStatus,
       // for each locale keys
-      Object.fromEntries(status.map(templateStatus => [templateStatus.locale, templateStatus])),
-    );
+      ...Object.fromEntries(status.map(templateStatus => [templateStatus.locale, templateStatus])),
+    });
   }
 
   return summaries;
@@ -167,8 +180,8 @@ export const validateTemplatePlugin = async(projectDirRoot: string): Promise<boo
   // key: id
   // value: isValid properties
   const idValidMap: { [id: string]: boolean[] } = {};
-  Object.entries(results).forEach(([templateId, status]) => {
-    idValidMap[templateId] = Object.values(status).map(s => s?.isValid ?? false);
+  Object.entries(results).forEach(([index, summary]) => {
+    idValidMap[summary.default.id] = Object.values(summary).map(s => s?.isValid ?? false);
   });
 
   for (const [id, validMap] of Object.entries(idValidMap)) {