From a3171aedfc74f0b86f7cd6b6690efbd81ab3bb7c Mon Sep 17 00:00:00 2001 From: Boris Date: Wed, 18 Sep 2024 12:15:23 +0800 Subject: [PATCH] feat: dashboard (#860) * feat: plugins * feat: dashboard panel and plugin publish procedure * chore: rename dashboard db name * feat: base query add cell format params * feat: dashboard and plugin render * feat: dashboard permission controll * chore: remove chart page * feat: add isExpand status * feat: auth plugin render * feat: chart plugin * chore: add plugin chart scripts * chore: remove dist * feat: plugin docker build and chart plugin init * chore: plugin chart build * chore: plugin chart lint * fix: base query e2e * fix: markdown preview theme * fix: plugin e2e * fix: first admin user * fix: insert env in nextjs-app/.env * fix: e2e error * fix: plugin rows * fix: plugin and dashboard service spec * fix: init official plugin lock attachments database table * fix: test error * fix: init plugin conflict on e2e * fix: init plugin conflict on e2e * fix: init plugin conflict on e2e * fix: init plugin conflict on e2e * chore: better message * fix: init plugin conflict on e2e * chore: remove lock --- apps/nestjs-backend/package.json | 1 + apps/nestjs-backend/src/app.module.ts | 4 + apps/nestjs-backend/src/cache/types.ts | 5 + .../nestjs-backend/src/configs/base.config.ts | 1 + .../access-token/access-token.service.ts | 22 +- .../attachments/attachments.controller.ts | 1 + .../features/attachments/plugins/adapter.ts | 3 + .../src/features/attachments/plugins/local.ts | 8 +- .../src/features/auth/permission.service.ts | 2 +- .../auth/strategies/access-token.strategy.ts | 2 +- .../base/base-query/base-query.service.ts | 68 +++- .../src/features/base/base.controller.ts | 3 +- .../src/features/base/base.module.ts | 10 +- .../dashboard/dashboard.controller.spec.ts | 19 + .../dashboard/dashboard.controller.ts | 135 +++++++ .../features/dashboard/dashboard.module.ts | 9 + .../dashboard/dashboard.service.spec.ts | 21 + .../features/dashboard/dashboard.service.ts | 363 ++++++++++++++++++ .../src/features/next/next.controller.ts | 1 - .../src/features/next/next.module.ts | 3 +- .../next/plugin/plugin-proxy.middleware.ts | 21 + .../next/plugin/plugin-proxy.module.ts | 16 + .../src/features/next/plugin/plugin.module.ts | 8 + .../features/plugin/official/config/chart.ts | 32 ++ .../official/official-plugin-init.service.ts | 165 ++++++++ .../features/plugin/plugin-auth.service.ts | 191 +++++++++ .../features/plugin/plugin.controller.spec.ts | 19 + .../src/features/plugin/plugin.controller.ts | 109 ++++++ .../src/features/plugin/plugin.module.ts | 30 ++ .../features/plugin/plugin.service.spec.ts | 21 + .../src/features/plugin/plugin.service.ts | 361 +++++++++++++++++ .../src/features/plugin/utils.ts | 15 + .../src/features/setting/admin.controller.ts | 14 + .../src/features/setting/admin.service.ts | 15 + .../src/features/setting/setting.module.ts | 6 +- .../src/features/user/user.service.ts | 31 +- apps/nestjs-backend/static/plugin/chart.png | Bin 0 -> 12400 bytes .../test/base-query.e2e-spec.ts | 2 - .../test/computed-user-field.e2e-spec.ts | 9 +- .../nestjs-backend/test/dashboard.e2e-spec.ts | 157 ++++++++ .../test/oauth-server.e2e-spec.ts | 81 ++-- apps/nestjs-backend/test/plugin.e2e-spec.ts | 146 +++++++ apps/nextjs-app/package.json | 3 + .../app/blocks/chart/ChartDisplay.tsx | 139 ------- .../features/app/blocks/chart/ChartLayout.tsx | 45 --- .../features/app/blocks/chart/ChartPage.tsx | 40 -- .../app/blocks/chart/chart-show/Bar.tsx | 74 ---- .../FormPageLayout.tsx} | 11 +- .../setting/oauth-app/manage/OAuthAppEdit.tsx | 6 +- .../setting/oauth-app/manage/OAuthAppNew.tsx | 6 +- .../blocks/setting/plugin/MarkDownEditor.tsx | 48 +++ .../app/blocks/setting/plugin/PluginEdit.tsx | 257 +++++++++++++ .../app/blocks/setting/plugin/PluginList.tsx | 93 +++++ .../app/blocks/setting/plugin/PluginNew.tsx | 185 +++++++++ .../app/blocks/setting/plugin/PluginPage.tsx | 68 ++++ .../setting/plugin/component/JsonEditor.tsx | 32 ++ .../setting/plugin/component/LogoEditor.tsx | 100 +++++ .../setting/plugin/component/NewSecret.tsx | 21 + .../plugin/component/PositionSelector.tsx | 40 ++ .../app/components/MarkdownPreview.tsx | 27 ++ .../app/components/sidebar/Sidebar.tsx | 2 +- .../toggle-side-bar/HoverWrapper.tsx | 2 +- .../app/components/user/UserAvatar.tsx | 1 + .../features/app/dashboard/DashboardGrid.tsx | 103 +++++ .../app/dashboard/DashboardHeader.tsx | 120 ++++++ .../features/app/dashboard/DashboardMain.tsx | 52 +++ .../features/app/dashboard/EmptyDashboard.tsx | 23 ++ .../features/app/dashboard/GridContent.tsx | 76 ---- .../src/features/app/dashboard/Pages.tsx | 66 ++-- .../dashboard/components/AddPluginDialog.tsx | 100 +++++ .../app/dashboard/components/BarChart.tsx | 35 -- .../components/CreateDashboardDialog.tsx | 104 +++++ .../components/DashboardSwitcher.tsx | 114 ++++++ .../app/dashboard/components/LineChart.tsx | 88 ----- .../app/dashboard/components/Pickers.tsx | 74 ---- .../app/dashboard/components/PieChart.tsx | 63 --- .../dashboard/components/PluginContent.tsx | 88 +++++ .../app/dashboard/components/PluginDetail.tsx | 79 ++++ .../app/dashboard/components/PluginHeader.tsx | 116 ++++++ .../app/dashboard/components/PluginItem.tsx | 95 +++++ .../app/dashboard/components/PluginRender.tsx | 60 +++ .../app/dashboard/components/RecentSales.tsx | 63 --- .../app/dashboard/components/Search.tsx | 9 - .../app/dashboard/hooks/useAggregates.ts | 62 --- .../app/dashboard/hooks/useChartData.ts | 66 ---- .../app/dashboard/hooks/useIsExpandPlugin.ts | 11 + .../app/dashboard/hooks/useLineChartData.ts | 62 --- .../features/app/layouts/useSettingRoute.tsx | 6 + .../src/features/i18n/dashboard.config.ts | 4 +- .../features/i18n/setting-plugin.config.ts | 9 + apps/nextjs-app/src/pages/_app.tsx | 1 - apps/nextjs-app/src/pages/base/[baseId].tsx | 55 ++- apps/nextjs-app/src/pages/plugin/chart.tsx | 32 -- apps/nextjs-app/src/pages/setting/plugin.tsx | 24 ++ apps/nextjs-app/src/styles/global.css | 7 + dockers/teable/Dockerfile | 9 +- package.json | 1 + packages/common-i18n/src/I18nNamespaces.ts | 4 + .../common-i18n/src/locales/en/dashboard.json | 23 ++ .../common-i18n/src/locales/en/plugin.json | 56 +++ .../common-i18n/src/locales/en/setting.json | 3 +- .../common-i18n/src/locales/zh/dashboard.json | 23 ++ .../common-i18n/src/locales/zh/plugin.json | 56 +++ .../common-i18n/src/locales/zh/setting.json | 3 +- packages/core/src/asserts/index.ts | 1 + packages/core/src/asserts/lang.ts | 1 + packages/core/src/auth/role/constant.ts | 2 +- packages/core/src/auth/system.ts | 2 + packages/core/src/utils/id-generator.ts | 22 ++ .../migration.sql | 62 +++ .../prisma/postgres/schema.prisma | 58 ++- .../migration.sql | 54 +++ .../prisma/sqlite/schema.prisma | 58 ++- .../db-main-prisma/prisma/template.prisma | 58 ++- .../src/seeds/e2e/user-seeds.ts | 1 + packages/icons/package.json | 3 - packages/openapi/src/admin/index.ts | 2 + packages/openapi/src/admin/plugin/index.ts | 1 + packages/openapi/src/admin/plugin/publish.ts | 27 ++ .../openapi/src/{ => admin}/setting/get.ts | 4 +- .../openapi/src/{ => admin}/setting/index.ts | 0 .../openapi/src/{ => admin}/setting/update.ts | 4 +- packages/openapi/src/attachment/signature.ts | 1 + packages/openapi/src/base/query-data/route.ts | 11 +- packages/openapi/src/dashboard/create.ts | 55 +++ packages/openapi/src/dashboard/delete.ts | 28 ++ packages/openapi/src/dashboard/get-list.ts | 41 ++ packages/openapi/src/dashboard/get.ts | 43 +++ packages/openapi/src/dashboard/index.ts | 12 + packages/openapi/src/dashboard/plugin-get.ts | 52 +++ .../openapi/src/dashboard/plugin-install.ts | 59 +++ .../openapi/src/dashboard/plugin-remove.ts | 36 ++ .../openapi/src/dashboard/plugin-rename.ts | 64 +++ .../src/dashboard/plugin-update-storage.ts | 67 ++++ packages/openapi/src/dashboard/rename.ts | 55 +++ packages/openapi/src/dashboard/types.ts | 26 ++ .../openapi/src/dashboard/update-layout.ts | 61 +++ packages/openapi/src/index.ts | 4 +- packages/openapi/src/plugin/create.ts | 67 ++++ packages/openapi/src/plugin/delete.ts | 31 ++ packages/openapi/src/plugin/get-auth-code.ts | 44 +++ .../openapi/src/plugin/get-center-list.ts | 76 ++++ packages/openapi/src/plugin/get-list.ts | 48 +++ packages/openapi/src/plugin/get-token.ts | 58 +++ packages/openapi/src/plugin/get.ts | 56 +++ packages/openapi/src/plugin/index.ts | 12 + packages/openapi/src/plugin/refresh-token.ts | 51 +++ .../openapi/src/plugin/regenerate-secret.ts | 43 +++ packages/openapi/src/plugin/submit.ts | 27 ++ packages/openapi/src/plugin/types.ts | 52 +++ packages/openapi/src/plugin/update.ts | 72 ++++ packages/sdk/package.json | 8 +- .../components/base-query/QueryBuilder.tsx | 22 +- .../src/components/cell-value/CellValue.tsx | 5 +- .../cell-attachment/CellAttachment.tsx | 1 + .../cell-value/cell-user/CellUser.tsx | 13 +- .../cell-value/cell-user/UserAvatar.tsx | 15 +- .../cell-value/cell-user/UserTag.tsx | 5 +- packages/sdk/src/config/react-query-keys.ts | 4 + packages/sdk/src/index.ts | 1 + packages/sdk/src/plugin-bridge/bridge.ts | 79 ++++ packages/sdk/src/plugin-bridge/hooks/index.ts | 1 + .../sdk/src/plugin-bridge/hooks/use-bridge.ts | 26 ++ packages/sdk/src/plugin-bridge/index.ts | 4 + packages/sdk/src/plugin-bridge/types.ts | 33 ++ packages/sdk/src/plugin-bridge/utils.ts | 1 + packages/ui-lib/package.json | 6 +- plugins/chart/.env | 3 + plugins/chart/.eslintrc.js | 58 +++ plugins/chart/.gitignore | 36 ++ plugins/chart/README.md | 1 + plugins/chart/next-env.d.ts | 5 + plugins/chart/next.config.mjs | 51 +++ plugins/chart/package.json | 47 +++ plugins/chart/postcss.config.js | 9 + plugins/chart/scripts/build-replace.js | 44 +++ plugins/chart/src/api.ts | 20 + plugins/chart/src/app/api/backend.ts | 166 ++++++++ .../src/app/api/plugin/getToken/route.ts | 38 ++ .../src/app/api/plugin/getToken/uilts.ts | 90 +++++ plugins/chart/src/app/favicon.ico | Bin 0 -> 12400 bytes .../chart/src/app/globals.css | 4 + plugins/chart/src/app/layout.tsx | 19 + plugins/chart/src/app/page.tsx | 27 ++ .../chart/src/components/ChartProvider.tsx | 64 +++ plugins/chart/src/components/EnvProvider.tsx | 30 ++ plugins/chart/src/components/I18nProvider.tsx | 70 ++++ plugins/chart/src/components/Pages.tsx | 102 +++++ .../src/components/QueryClientProvider.tsx | 31 ++ .../src/components/chart/ChartLayout.tsx | 48 +++ .../chart/src/components/chart/ChartPage.tsx | 49 +++ .../chart/src/components/chart/ChartQuery.tsx | 70 ++++ .../chart/chart-config/ChartForm.tsx | 28 ++ .../chart/chart-config/ChartSetting.tsx | 50 +++ .../chart/chart-config/QueryStatus.tsx | 51 +++ .../chart/chart-config/TypeSelector.tsx | 87 +++++ .../common/AxisDisplayBaseContent.tsx | 72 ++++ .../chart-config/common/ColumnSelector.tsx | 36 ++ .../common/ComboLineStyleEditor.tsx | 47 +++ .../chart-config/common/ComboTypeEditor.tsx | 49 +++ .../common/ComboXAxisDisplayEditor.tsx | 32 ++ .../common/ComboYAisxDisplayEditor.tsx | 69 ++++ .../chart/chart-config/common/ConfigItem.tsx | 15 + .../chart-config/common/GoalLineEditor.tsx | 68 ++++ .../chart-config/common/SwitchEditor.tsx | 25 ++ .../common/YAxisPositionEditor.tsx | 45 +++ .../chart/chart-config/form/AreaForm.tsx | 37 ++ .../chart/chart-config/form/BarForm.tsx | 33 ++ .../chart/chart-config/form/ComboForm.tsx | 243 ++++++++++++ .../chart-config/form/ComboXAxisEditor.tsx | 81 ++++ .../chart-config/form/ComboYAxisEditor.tsx | 117 ++++++ .../chart/chart-config/form/LineForm.tsx | 22 ++ .../chart/chart-config/form/PieForm.tsx | 149 +++++++ .../chart/chart-config/form/utils.ts | 43 +++ .../chart/chart-show/ChartDisplay.tsx | 39 ++ .../chart/chart-show/combo/Combo.tsx | 301 +++++++++++++++ .../chart/chart-show/combo/TooltipItem.tsx | 27 ++ .../chart/chart-show/combo/useComboConfig.ts | 33 ++ .../components/chart/chart-show/pie/Pie.tsx | 194 ++++++++++ .../chart/chart-show/pie/usePieConfig.tsx | 21 + .../chart/chart-show/pie/useRefObserve.ts | 36 ++ .../chart/chart-show/table/ChartTable.tsx | 42 ++ .../src/components/chart/chart-show/types.ts | 3 + .../src/components/chart/chart-show/utils.ts | 5 + plugins/chart/src/components/types.ts | 138 +++++++ plugins/chart/src/constant.ts | 1 + plugins/chart/src/hooks/useBasePermissions.ts | 23 ++ plugins/chart/src/hooks/useBaseQueryData.ts | 65 ++++ plugins/chart/src/hooks/useEnv.ts | 6 + .../chart/src/hooks/useFilterNumberColumns.ts | 17 + plugins/chart/src/hooks/useUIConfig.ts | 7 + plugins/chart/src/locales/en.json | 85 ++++ plugins/chart/src/locales/zh.json | 85 ++++ plugins/chart/src/types.d/i18next.d.ts | 13 + plugins/chart/tailwind.config.ts | 12 + plugins/chart/tsconfig.json | 23 ++ pnpm-lock.yaml | 321 +++++++++++++++- pnpm-workspace.yaml | 1 + scripts/build-image.mjs | 2 +- scripts/start.mjs | 5 - scripts/start.sh | 5 + 241 files changed, 10091 insertions(+), 1111 deletions(-) create mode 100644 apps/nestjs-backend/src/features/dashboard/dashboard.controller.spec.ts create mode 100644 apps/nestjs-backend/src/features/dashboard/dashboard.controller.ts create mode 100644 apps/nestjs-backend/src/features/dashboard/dashboard.module.ts create mode 100644 apps/nestjs-backend/src/features/dashboard/dashboard.service.spec.ts create mode 100644 apps/nestjs-backend/src/features/dashboard/dashboard.service.ts create mode 100644 apps/nestjs-backend/src/features/next/plugin/plugin-proxy.middleware.ts create mode 100644 apps/nestjs-backend/src/features/next/plugin/plugin-proxy.module.ts create mode 100644 apps/nestjs-backend/src/features/next/plugin/plugin.module.ts create mode 100644 apps/nestjs-backend/src/features/plugin/official/config/chart.ts create mode 100644 apps/nestjs-backend/src/features/plugin/official/official-plugin-init.service.ts create mode 100644 apps/nestjs-backend/src/features/plugin/plugin-auth.service.ts create mode 100644 apps/nestjs-backend/src/features/plugin/plugin.controller.spec.ts create mode 100644 apps/nestjs-backend/src/features/plugin/plugin.controller.ts create mode 100644 apps/nestjs-backend/src/features/plugin/plugin.module.ts create mode 100644 apps/nestjs-backend/src/features/plugin/plugin.service.spec.ts create mode 100644 apps/nestjs-backend/src/features/plugin/plugin.service.ts create mode 100644 apps/nestjs-backend/src/features/plugin/utils.ts create mode 100644 apps/nestjs-backend/src/features/setting/admin.controller.ts create mode 100644 apps/nestjs-backend/src/features/setting/admin.service.ts create mode 100644 apps/nestjs-backend/static/plugin/chart.png create mode 100644 apps/nestjs-backend/test/dashboard.e2e-spec.ts create mode 100644 apps/nestjs-backend/test/plugin.e2e-spec.ts delete mode 100644 apps/nextjs-app/src/features/app/blocks/chart/ChartDisplay.tsx delete mode 100644 apps/nextjs-app/src/features/app/blocks/chart/ChartLayout.tsx delete mode 100644 apps/nextjs-app/src/features/app/blocks/chart/ChartPage.tsx delete mode 100644 apps/nextjs-app/src/features/app/blocks/chart/chart-show/Bar.tsx rename apps/nextjs-app/src/features/app/blocks/setting/{oauth-app/manage/OAuthAppDetailLayout.tsx => components/FormPageLayout.tsx} (69%) create mode 100644 apps/nextjs-app/src/features/app/blocks/setting/plugin/MarkDownEditor.tsx create mode 100644 apps/nextjs-app/src/features/app/blocks/setting/plugin/PluginEdit.tsx create mode 100644 apps/nextjs-app/src/features/app/blocks/setting/plugin/PluginList.tsx create mode 100644 apps/nextjs-app/src/features/app/blocks/setting/plugin/PluginNew.tsx create mode 100644 apps/nextjs-app/src/features/app/blocks/setting/plugin/PluginPage.tsx create mode 100644 apps/nextjs-app/src/features/app/blocks/setting/plugin/component/JsonEditor.tsx create mode 100644 apps/nextjs-app/src/features/app/blocks/setting/plugin/component/LogoEditor.tsx create mode 100644 apps/nextjs-app/src/features/app/blocks/setting/plugin/component/NewSecret.tsx create mode 100644 apps/nextjs-app/src/features/app/blocks/setting/plugin/component/PositionSelector.tsx create mode 100644 apps/nextjs-app/src/features/app/components/MarkdownPreview.tsx create mode 100644 apps/nextjs-app/src/features/app/dashboard/DashboardGrid.tsx create mode 100644 apps/nextjs-app/src/features/app/dashboard/DashboardHeader.tsx create mode 100644 apps/nextjs-app/src/features/app/dashboard/DashboardMain.tsx create mode 100644 apps/nextjs-app/src/features/app/dashboard/EmptyDashboard.tsx delete mode 100644 apps/nextjs-app/src/features/app/dashboard/GridContent.tsx create mode 100644 apps/nextjs-app/src/features/app/dashboard/components/AddPluginDialog.tsx delete mode 100644 apps/nextjs-app/src/features/app/dashboard/components/BarChart.tsx create mode 100644 apps/nextjs-app/src/features/app/dashboard/components/CreateDashboardDialog.tsx create mode 100644 apps/nextjs-app/src/features/app/dashboard/components/DashboardSwitcher.tsx delete mode 100644 apps/nextjs-app/src/features/app/dashboard/components/LineChart.tsx delete mode 100644 apps/nextjs-app/src/features/app/dashboard/components/Pickers.tsx delete mode 100644 apps/nextjs-app/src/features/app/dashboard/components/PieChart.tsx create mode 100644 apps/nextjs-app/src/features/app/dashboard/components/PluginContent.tsx create mode 100644 apps/nextjs-app/src/features/app/dashboard/components/PluginDetail.tsx create mode 100644 apps/nextjs-app/src/features/app/dashboard/components/PluginHeader.tsx create mode 100644 apps/nextjs-app/src/features/app/dashboard/components/PluginItem.tsx create mode 100644 apps/nextjs-app/src/features/app/dashboard/components/PluginRender.tsx delete mode 100644 apps/nextjs-app/src/features/app/dashboard/components/RecentSales.tsx delete mode 100644 apps/nextjs-app/src/features/app/dashboard/components/Search.tsx delete mode 100644 apps/nextjs-app/src/features/app/dashboard/hooks/useAggregates.ts delete mode 100644 apps/nextjs-app/src/features/app/dashboard/hooks/useChartData.ts create mode 100644 apps/nextjs-app/src/features/app/dashboard/hooks/useIsExpandPlugin.ts delete mode 100644 apps/nextjs-app/src/features/app/dashboard/hooks/useLineChartData.ts create mode 100644 apps/nextjs-app/src/features/i18n/setting-plugin.config.ts delete mode 100644 apps/nextjs-app/src/pages/plugin/chart.tsx create mode 100644 apps/nextjs-app/src/pages/setting/plugin.tsx create mode 100644 packages/common-i18n/src/locales/en/dashboard.json create mode 100644 packages/common-i18n/src/locales/en/plugin.json create mode 100644 packages/common-i18n/src/locales/zh/dashboard.json create mode 100644 packages/common-i18n/src/locales/zh/plugin.json create mode 100644 packages/core/src/asserts/lang.ts create mode 100644 packages/db-main-prisma/prisma/postgres/migrations/20240913075702_add_dashboard_plugin/migration.sql create mode 100644 packages/db-main-prisma/prisma/sqlite/migrations/20240913075658_add_dashboard_plugin/migration.sql create mode 100644 packages/openapi/src/admin/index.ts create mode 100644 packages/openapi/src/admin/plugin/index.ts create mode 100644 packages/openapi/src/admin/plugin/publish.ts rename packages/openapi/src/{ => admin}/setting/get.ts (91%) rename packages/openapi/src/{ => admin}/setting/index.ts (100%) rename packages/openapi/src/{ => admin}/setting/update.ts (92%) create mode 100644 packages/openapi/src/dashboard/create.ts create mode 100644 packages/openapi/src/dashboard/delete.ts create mode 100644 packages/openapi/src/dashboard/get-list.ts create mode 100644 packages/openapi/src/dashboard/get.ts create mode 100644 packages/openapi/src/dashboard/index.ts create mode 100644 packages/openapi/src/dashboard/plugin-get.ts create mode 100644 packages/openapi/src/dashboard/plugin-install.ts create mode 100644 packages/openapi/src/dashboard/plugin-remove.ts create mode 100644 packages/openapi/src/dashboard/plugin-rename.ts create mode 100644 packages/openapi/src/dashboard/plugin-update-storage.ts create mode 100644 packages/openapi/src/dashboard/rename.ts create mode 100644 packages/openapi/src/dashboard/types.ts create mode 100644 packages/openapi/src/dashboard/update-layout.ts create mode 100644 packages/openapi/src/plugin/create.ts create mode 100644 packages/openapi/src/plugin/delete.ts create mode 100644 packages/openapi/src/plugin/get-auth-code.ts create mode 100644 packages/openapi/src/plugin/get-center-list.ts create mode 100644 packages/openapi/src/plugin/get-list.ts create mode 100644 packages/openapi/src/plugin/get-token.ts create mode 100644 packages/openapi/src/plugin/get.ts create mode 100644 packages/openapi/src/plugin/index.ts create mode 100644 packages/openapi/src/plugin/refresh-token.ts create mode 100644 packages/openapi/src/plugin/regenerate-secret.ts create mode 100644 packages/openapi/src/plugin/submit.ts create mode 100644 packages/openapi/src/plugin/types.ts create mode 100644 packages/openapi/src/plugin/update.ts create mode 100644 packages/sdk/src/plugin-bridge/bridge.ts create mode 100644 packages/sdk/src/plugin-bridge/hooks/index.ts create mode 100644 packages/sdk/src/plugin-bridge/hooks/use-bridge.ts create mode 100644 packages/sdk/src/plugin-bridge/index.ts create mode 100644 packages/sdk/src/plugin-bridge/types.ts create mode 100644 packages/sdk/src/plugin-bridge/utils.ts create mode 100644 plugins/chart/.env create mode 100644 plugins/chart/.eslintrc.js create mode 100644 plugins/chart/.gitignore create mode 100644 plugins/chart/README.md create mode 100644 plugins/chart/next-env.d.ts create mode 100644 plugins/chart/next.config.mjs create mode 100644 plugins/chart/package.json create mode 100644 plugins/chart/postcss.config.js create mode 100644 plugins/chart/scripts/build-replace.js create mode 100644 plugins/chart/src/api.ts create mode 100644 plugins/chart/src/app/api/backend.ts create mode 100644 plugins/chart/src/app/api/plugin/getToken/route.ts create mode 100644 plugins/chart/src/app/api/plugin/getToken/uilts.ts create mode 100644 plugins/chart/src/app/favicon.ico rename apps/nextjs-app/src/features/app/blocks/chart/chart-show/theme.css => plugins/chart/src/app/globals.css (98%) create mode 100644 plugins/chart/src/app/layout.tsx create mode 100644 plugins/chart/src/app/page.tsx create mode 100644 plugins/chart/src/components/ChartProvider.tsx create mode 100644 plugins/chart/src/components/EnvProvider.tsx create mode 100644 plugins/chart/src/components/I18nProvider.tsx create mode 100644 plugins/chart/src/components/Pages.tsx create mode 100644 plugins/chart/src/components/QueryClientProvider.tsx create mode 100644 plugins/chart/src/components/chart/ChartLayout.tsx create mode 100644 plugins/chart/src/components/chart/ChartPage.tsx create mode 100644 plugins/chart/src/components/chart/ChartQuery.tsx create mode 100644 plugins/chart/src/components/chart/chart-config/ChartForm.tsx create mode 100644 plugins/chart/src/components/chart/chart-config/ChartSetting.tsx create mode 100644 plugins/chart/src/components/chart/chart-config/QueryStatus.tsx create mode 100644 plugins/chart/src/components/chart/chart-config/TypeSelector.tsx create mode 100644 plugins/chart/src/components/chart/chart-config/common/AxisDisplayBaseContent.tsx create mode 100644 plugins/chart/src/components/chart/chart-config/common/ColumnSelector.tsx create mode 100644 plugins/chart/src/components/chart/chart-config/common/ComboLineStyleEditor.tsx create mode 100644 plugins/chart/src/components/chart/chart-config/common/ComboTypeEditor.tsx create mode 100644 plugins/chart/src/components/chart/chart-config/common/ComboXAxisDisplayEditor.tsx create mode 100644 plugins/chart/src/components/chart/chart-config/common/ComboYAisxDisplayEditor.tsx create mode 100644 plugins/chart/src/components/chart/chart-config/common/ConfigItem.tsx create mode 100644 plugins/chart/src/components/chart/chart-config/common/GoalLineEditor.tsx create mode 100644 plugins/chart/src/components/chart/chart-config/common/SwitchEditor.tsx create mode 100644 plugins/chart/src/components/chart/chart-config/common/YAxisPositionEditor.tsx create mode 100644 plugins/chart/src/components/chart/chart-config/form/AreaForm.tsx create mode 100644 plugins/chart/src/components/chart/chart-config/form/BarForm.tsx create mode 100644 plugins/chart/src/components/chart/chart-config/form/ComboForm.tsx create mode 100644 plugins/chart/src/components/chart/chart-config/form/ComboXAxisEditor.tsx create mode 100644 plugins/chart/src/components/chart/chart-config/form/ComboYAxisEditor.tsx create mode 100644 plugins/chart/src/components/chart/chart-config/form/LineForm.tsx create mode 100644 plugins/chart/src/components/chart/chart-config/form/PieForm.tsx create mode 100644 plugins/chart/src/components/chart/chart-config/form/utils.ts create mode 100644 plugins/chart/src/components/chart/chart-show/ChartDisplay.tsx create mode 100644 plugins/chart/src/components/chart/chart-show/combo/Combo.tsx create mode 100644 plugins/chart/src/components/chart/chart-show/combo/TooltipItem.tsx create mode 100644 plugins/chart/src/components/chart/chart-show/combo/useComboConfig.ts create mode 100644 plugins/chart/src/components/chart/chart-show/pie/Pie.tsx create mode 100644 plugins/chart/src/components/chart/chart-show/pie/usePieConfig.tsx create mode 100644 plugins/chart/src/components/chart/chart-show/pie/useRefObserve.ts create mode 100644 plugins/chart/src/components/chart/chart-show/table/ChartTable.tsx create mode 100644 plugins/chart/src/components/chart/chart-show/types.ts create mode 100644 plugins/chart/src/components/chart/chart-show/utils.ts create mode 100644 plugins/chart/src/components/types.ts create mode 100644 plugins/chart/src/constant.ts create mode 100644 plugins/chart/src/hooks/useBasePermissions.ts create mode 100644 plugins/chart/src/hooks/useBaseQueryData.ts create mode 100644 plugins/chart/src/hooks/useEnv.ts create mode 100644 plugins/chart/src/hooks/useFilterNumberColumns.ts create mode 100644 plugins/chart/src/hooks/useUIConfig.ts create mode 100644 plugins/chart/src/locales/en.json create mode 100644 plugins/chart/src/locales/zh.json create mode 100644 plugins/chart/src/types.d/i18next.d.ts create mode 100644 plugins/chart/tailwind.config.ts create mode 100644 plugins/chart/tsconfig.json delete mode 100755 scripts/start.mjs create mode 100755 scripts/start.sh diff --git a/apps/nestjs-backend/package.json b/apps/nestjs-backend/package.json index 2b36f5f3b..ad99a86e6 100644 --- a/apps/nestjs-backend/package.json +++ b/apps/nestjs-backend/package.json @@ -159,6 +159,7 @@ "fs-extra": "11.2.0", "handlebars": "4.7.8", "helmet": "7.1.0", + "http-proxy-middleware": "3.0.2", "ioredis": "5.4.1", "is-port-reachable": "3.1.0", "joi": "17.12.2", diff --git a/apps/nestjs-backend/src/app.module.ts b/apps/nestjs-backend/src/app.module.ts index bf108c597..83f3d0830 100644 --- a/apps/nestjs-backend/src/app.module.ts +++ b/apps/nestjs-backend/src/app.module.ts @@ -7,6 +7,7 @@ import { AuthModule } from './features/auth/auth.module'; import { BaseModule } from './features/base/base.module'; import { ChatModule } from './features/chat/chat.module'; import { CollaboratorModule } from './features/collaborator/collaborator.module'; +import { DashboardModule } from './features/dashboard/dashboard.module'; import { ExportOpenApiModule } from './features/export/open-api/export-open-api.module'; import { FieldOpenApiModule } from './features/field/open-api/field-open-api.module'; import { HealthModule } from './features/health/health.module'; @@ -16,6 +17,7 @@ import { NextModule } from './features/next/next.module'; import { NotificationModule } from './features/notification/notification.module'; import { OAuthModule } from './features/oauth/oauth.module'; import { PinModule } from './features/pin/pin.module'; +import { PluginModule } from './features/plugin/plugin.module'; import { SelectionModule } from './features/selection/selection.module'; import { SettingModule } from './features/setting/setting.module'; import { ShareModule } from './features/share/share.module'; @@ -55,6 +57,8 @@ export const appModules = { SettingModule, OAuthModule, TrashModule, + PluginModule, + DashboardModule, ], providers: [InitBootstrapProvider], }; diff --git a/apps/nestjs-backend/src/cache/types.ts b/apps/nestjs-backend/src/cache/types.ts index eebc1be46..79951d8c0 100644 --- a/apps/nestjs-backend/src/cache/types.ts +++ b/apps/nestjs-backend/src/cache/types.ts @@ -22,6 +22,7 @@ export interface ICacheStore { // userId:tableId:windowId [key: `operations:undo:${string}:${string}:${string}`]: IUndoRedoOperation[]; [key: `operations:redo:${string}:${string}:${string}`]: IUndoRedoOperation[]; + [key: `plugin:auth-code:${string}`]: IPluginAuthStore; } export interface IAttachmentSignatureCache { @@ -242,3 +243,7 @@ export type IUndoRedoOperation = | ICreateViewOperation | IDeleteViewOperation | IUpdateViewOperation; +export interface IPluginAuthStore { + baseId: string; + pluginId: string; +} diff --git a/apps/nestjs-backend/src/configs/base.config.ts b/apps/nestjs-backend/src/configs/base.config.ts index a85d1d6e7..91ee8ec93 100644 --- a/apps/nestjs-backend/src/configs/base.config.ts +++ b/apps/nestjs-backend/src/configs/base.config.ts @@ -13,6 +13,7 @@ export const baseConfig = registerAs('base', () => ({ defaultMaxBaseDBConnections: Number(process.env.DEFAULT_MAX_BASE_DB_CONNECTIONS ?? 20), templateSpaceId: process.env.TEMPLATE_SPACE_ID, recordHistoryDisabled: process.env.RECORD_HISTORY_DISABLED === 'true', + pluginServerPort: process.env.PLUGIN_SERVER_PORT || '3002', })); export const BaseConfig = () => Inject(baseConfig.KEY); diff --git a/apps/nestjs-backend/src/features/access-token/access-token.service.ts b/apps/nestjs-backend/src/features/access-token/access-token.service.ts index cfe16e8fb..54d864baf 100644 --- a/apps/nestjs-backend/src/features/access-token/access-token.service.ts +++ b/apps/nestjs-backend/src/features/access-token/access-token.service.ts @@ -45,15 +45,19 @@ export class AccessTokenService { async validate(splitAccessTokenObj: { accessTokenId: string; sign: string }) { const { accessTokenId, sign } = splitAccessTokenObj; - const accessTokenEntity = await this.prismaService.accessToken.findUniqueOrThrow({ - where: { id: accessTokenId }, - select: { - userId: true, - id: true, - sign: true, - expiredTime: true, - }, - }); + const accessTokenEntity = await this.prismaService.accessToken + .findUniqueOrThrow({ + where: { id: accessTokenId }, + select: { + userId: true, + id: true, + sign: true, + expiredTime: true, + }, + }) + .catch(() => { + throw new UnauthorizedException('token not found'); + }); if (sign !== accessTokenEntity.sign) { throw new UnauthorizedException('sign error'); } diff --git a/apps/nestjs-backend/src/features/attachments/attachments.controller.ts b/apps/nestjs-backend/src/features/attachments/attachments.controller.ts index 730658df3..fc5136f09 100644 --- a/apps/nestjs-backend/src/features/attachments/attachments.controller.ts +++ b/apps/nestjs-backend/src/features/attachments/attachments.controller.ts @@ -66,6 +66,7 @@ export class AttachmentsController { headers['Content-Disposition'] = responseContentDisposition; } } + headers['Cross-Origin-Resource-Policy'] = 'unsafe-none'; res.set(headers); return new StreamableFile(fileStream); } diff --git a/apps/nestjs-backend/src/features/attachments/plugins/adapter.ts b/apps/nestjs-backend/src/features/attachments/plugins/adapter.ts index 09d2723de..9f4f37a8f 100644 --- a/apps/nestjs-backend/src/features/attachments/plugins/adapter.ts +++ b/apps/nestjs-backend/src/features/attachments/plugins/adapter.ts @@ -13,6 +13,7 @@ export default abstract class StorageAdapter { case UploadType.Avatar: case UploadType.OAuth: case UploadType.Form: + case UploadType.Plugin: return storageConfig().publicBucket; default: throw new BadRequestException('Invalid upload type'); @@ -31,6 +32,8 @@ export default abstract class StorageAdapter { return 'oauth'; case UploadType.Import: return 'import'; + case UploadType.Plugin: + return 'plugin'; default: throw new BadRequestException('Invalid upload type'); } diff --git a/apps/nestjs-backend/src/features/attachments/plugins/local.ts b/apps/nestjs-backend/src/features/attachments/plugins/local.ts index fdb65d6c0..b7147f640 100644 --- a/apps/nestjs-backend/src/features/attachments/plugins/local.ts +++ b/apps/nestjs-backend/src/features/attachments/plugins/local.ts @@ -149,11 +149,13 @@ export class LocalStorage implements StorageAdapter { }); } - async save(filePath: string, rename: string) { + async save(filePath: string, rename: string, isDelete: boolean = true) { const distPath = resolve(this.storageDir); const newFilePath = resolve(distPath, rename); await fse.copy(filePath, newFilePath); - await fse.remove(filePath); + if (isDelete) { + await fse.remove(filePath); + } return join(this.path, rename); } @@ -239,7 +241,7 @@ export class LocalStorage implements StorageAdapter { _metadata: Record ) { const hash = await FileUtils.getHash(filePath); - await this.save(filePath, join(bucket, path)); + await this.save(filePath, join(bucket, path), false); return { hash, path, diff --git a/apps/nestjs-backend/src/features/auth/permission.service.ts b/apps/nestjs-backend/src/features/auth/permission.service.ts index 2e655433f..e5dedb9c9 100644 --- a/apps/nestjs-backend/src/features/auth/permission.service.ts +++ b/apps/nestjs-backend/src/features/auth/permission.service.ts @@ -81,7 +81,7 @@ export class PermissionService { select: { scopes: true, spaceIds: true, baseIds: true, clientId: true, userId: true }, }); const scopes = JSON.parse(stringifyScopes) as Action[]; - if (clientId) { + if (clientId && clientId.startsWith(IdPrefix.OAuthClient)) { const { spaceIds: spaceIdsByOAuth, baseIds: baseIdsByOAuth } = await this.getOAuthAccessBy(userId); return { diff --git a/apps/nestjs-backend/src/features/auth/strategies/access-token.strategy.ts b/apps/nestjs-backend/src/features/auth/strategies/access-token.strategy.ts index e195223bc..f63a64e57 100644 --- a/apps/nestjs-backend/src/features/auth/strategies/access-token.strategy.ts +++ b/apps/nestjs-backend/src/features/auth/strategies/access-token.strategy.ts @@ -30,7 +30,7 @@ export class AccessTokenStrategy extends PassportStrategy(PassportAccessTokenStr const user = await this.userService.getUserById(userId); if (!user) { - throw new UnauthorizedException(); + throw new UnauthorizedException('User not found'); } if (user.deactivatedTime) { throw new UnauthorizedException('Your account has been deactivated by the administrator'); diff --git a/apps/nestjs-backend/src/features/base/base-query/base-query.service.ts b/apps/nestjs-backend/src/features/base/base-query/base-query.service.ts index 68ed89ee4..90285dca6 100644 --- a/apps/nestjs-backend/src/features/base/base-query/base-query.service.ts +++ b/apps/nestjs-backend/src/features/base/base-query/base-query.service.ts @@ -1,4 +1,6 @@ import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import type { IAttachmentCellValue } from '@teable/core'; +import { CellFormat, FieldType } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; import { BaseQueryColumnType, BaseQueryJoinType } from '@teable/openapi'; import type { IBaseQueryJoin, IBaseQuery, IBaseQueryVo, IBaseQueryColumn } from '@teable/openapi'; @@ -14,6 +16,7 @@ import { createFieldInstanceByVo, type IFieldInstance, } from '../../field/model/factory'; +import { RecordService } from '../../record/record.service'; import { QueryAggregation } from './parse/aggregation'; import { QueryFilter } from './parse/filter'; import { QueryGroup } from './parse/group'; @@ -31,7 +34,8 @@ export class BaseQueryService { private readonly fieldService: FieldService, private readonly prismaService: PrismaService, - private readonly cls: ClsService + private readonly cls: ClsService, + private readonly recordService: RecordService ) {} private convertFieldMapToColumn(fieldMap: Record): IBaseQueryColumn[] { @@ -48,23 +52,56 @@ export class BaseQueryService { }); } - private handleBigIntRows(rows: { [key in string]: unknown }[]) { - return rows.map((row) => { - return Object.entries(row).reduce( - (acc, [key, value]) => { + // eslint-disable-next-line sonarjs/cognitive-complexity + private async dbRows2Rows( + rows: Record[], + columns: IBaseQueryColumn[], + cellFormat: CellFormat + ) { + const resRows: Record[] = []; + for (const row of rows) { + const resRow: Record = {}; + for (const field of columns) { + if (!field.fieldSource) { + const value = row[field.column]; + resRow[field.column] = row[field.column]; + // handle bigint if (typeof value === 'bigint') { - acc[key] = Number(value); + resRow[field.column] = Number(value); } else { - acc[key] = value; + resRow[field.column] = value; } - return acc; - }, - {} as { [key in string]: unknown } - ); - }); + continue; + } + const dbCellValue = row[field.column]; + const fieldInstance = createFieldInstanceByVo(field.fieldSource); + const cellValue = fieldInstance.convertDBValue2CellValue(dbCellValue); + + // number no need to convert string + if (typeof cellValue === 'number') { + resRow[field.column] = cellValue; + continue; + } + if (cellValue != null) { + resRow[field.column] = + cellFormat === CellFormat.Text ? fieldInstance.cellValue2String(cellValue) : cellValue; + } + if (fieldInstance.type === FieldType.Attachment) { + resRow[field.column] = await this.recordService.getAttachmentPresignedCellValue( + cellValue as IAttachmentCellValue + ); + } + } + resRows.push(resRow); + } + return resRows; } - async baseQuery(baseId: string, baseQuery: IBaseQuery): Promise { + async baseQuery( + baseId: string, + baseQuery: IBaseQuery, + cellFormat: CellFormat = CellFormat.Json + ): Promise { const { queryBuilder, fieldMap } = await this.parseBaseQuery(baseId, baseQuery, 0); const query = queryBuilder.toQuery(); this.logger.log('baseQuery SQL: ', query); @@ -74,10 +111,11 @@ export class BaseQueryService { this.logger.error(e); throw new BadRequestException(`Query failed: ${query}, ${e.message}`); }); + const columns = this.convertFieldMapToColumn(fieldMap); return { - rows: this.handleBigIntRows(rows), - columns: this.convertFieldMapToColumn(fieldMap), + rows: await this.dbRows2Rows(rows, columns, cellFormat), + columns, }; } diff --git a/apps/nestjs-backend/src/features/base/base.controller.ts b/apps/nestjs-backend/src/features/base/base.controller.ts index 775559c17..f07b6052e 100644 --- a/apps/nestjs-backend/src/features/base/base.controller.ts +++ b/apps/nestjs-backend/src/features/base/base.controller.ts @@ -174,11 +174,12 @@ export class BaseController { } @Get(':baseId/query') + @Permissions('base|query_data') async sqlQuery( @Param('baseId') baseId: string, @Query(new ZodValidationPipe(baseQuerySchemaRo)) query: IBaseQuerySchemaRo ) { - return this.baseQueryService.baseQuery(baseId, query.query); + return this.baseQueryService.baseQuery(baseId, query.query, query.cellFormat); } @Permissions('base|invite_link') diff --git a/apps/nestjs-backend/src/features/base/base.module.ts b/apps/nestjs-backend/src/features/base/base.module.ts index a0176d9eb..0fd714c61 100644 --- a/apps/nestjs-backend/src/features/base/base.module.ts +++ b/apps/nestjs-backend/src/features/base/base.module.ts @@ -3,6 +3,7 @@ import { DbProvider } from '../../db-provider/db.provider'; import { CollaboratorModule } from '../collaborator/collaborator.module'; import { FieldModule } from '../field/field.module'; import { InvitationModule } from '../invitation/invitation.module'; +import { RecordModule } from '../record/record.module'; import { TableOpenApiModule } from '../table/open-api/table-open-api.module'; import { TableModule } from '../table/table.module'; import { BaseDuplicateService } from './base-duplicate.service'; @@ -13,7 +14,14 @@ import { DbConnectionService } from './db-connection.service'; @Module({ controllers: [BaseController], - imports: [CollaboratorModule, FieldModule, TableModule, InvitationModule, TableOpenApiModule], + imports: [ + CollaboratorModule, + FieldModule, + TableModule, + InvitationModule, + TableOpenApiModule, + RecordModule, + ], providers: [DbProvider, BaseService, DbConnectionService, BaseDuplicateService, BaseQueryService], exports: [BaseService, DbConnectionService, BaseDuplicateService], }) diff --git a/apps/nestjs-backend/src/features/dashboard/dashboard.controller.spec.ts b/apps/nestjs-backend/src/features/dashboard/dashboard.controller.spec.ts new file mode 100644 index 000000000..e5b08e8b9 --- /dev/null +++ b/apps/nestjs-backend/src/features/dashboard/dashboard.controller.spec.ts @@ -0,0 +1,19 @@ +import type { TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; +import { DashboardController } from './dashboard.controller'; + +describe('DashboardController', () => { + let controller: DashboardController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [DashboardController], + }).compile(); + + controller = module.get(DashboardController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/apps/nestjs-backend/src/features/dashboard/dashboard.controller.ts b/apps/nestjs-backend/src/features/dashboard/dashboard.controller.ts new file mode 100644 index 000000000..bd4a2d9f9 --- /dev/null +++ b/apps/nestjs-backend/src/features/dashboard/dashboard.controller.ts @@ -0,0 +1,135 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common'; +import { + createDashboardRoSchema, + dashboardInstallPluginRoSchema, + ICreateDashboardRo, + IRenameDashboardRo, + IUpdateLayoutDashboardRo, + renameDashboardRoSchema, + updateLayoutDashboardRoSchema, + IDashboardInstallPluginRo, + dashboardPluginUpdateStorageRoSchema, + IDashboardPluginUpdateStorageRo, +} from '@teable/openapi'; +import type { + ICreateDashboardVo, + IGetDashboardVo, + IRenameDashboardVo, + IUpdateLayoutDashboardVo, + IGetDashboardListVo, + IDashboardInstallPluginVo, + IDashboardPluginUpdateStorageVo, + IGetDashboardInstallPluginVo, +} from '@teable/openapi'; +import { ZodValidationPipe } from '../../zod.validation.pipe'; +import { Permissions } from '../auth/decorators/permissions.decorator'; +import { DashboardService } from './dashboard.service'; + +@Controller('api/base/:baseId/dashboard') +export class DashboardController { + constructor(private readonly dashboardService: DashboardService) {} + + @Get() + @Permissions('base|read') + getDashboard(@Param('baseId') baseId: string): Promise { + return this.dashboardService.getDashboard(baseId); + } + + @Get(':id') + @Permissions('base|read') + getDashboardById( + @Param('baseId') baseId: string, + @Param('id') id: string + ): Promise { + return this.dashboardService.getDashboardById(baseId, id); + } + + @Post() + @Permissions('base|update') + createDashboard( + @Param('baseId') baseId: string, + @Body(new ZodValidationPipe(createDashboardRoSchema)) ro: ICreateDashboardRo + ): Promise { + return this.dashboardService.createDashboard(baseId, ro); + } + + @Patch(':id/rename') + @Permissions('base|update') + updateDashboard( + @Param('baseId') baseId: string, + @Param('id') id: string, + @Body(new ZodValidationPipe(renameDashboardRoSchema)) ro: IRenameDashboardRo + ): Promise { + return this.dashboardService.renameDashboard(baseId, id, ro.name); + } + + @Patch(':id/layout') + @Permissions('base|update') + updateLayout( + @Param('baseId') baseId: string, + @Param('id') id: string, + @Body(new ZodValidationPipe(updateLayoutDashboardRoSchema)) ro: IUpdateLayoutDashboardRo + ): Promise { + return this.dashboardService.updateLayout(baseId, id, ro.layout); + } + + @Delete(':id') + @Permissions('base|update') + deleteDashboard(@Param('baseId') baseId: string, @Param('id') id: string): Promise { + return this.dashboardService.deleteDashboard(baseId, id); + } + + @Post(':id/plugin') + @Permissions('base|update') + installPlugin( + @Param('baseId') baseId: string, + @Param('id') id: string, + @Body(new ZodValidationPipe(dashboardInstallPluginRoSchema)) ro: IDashboardInstallPluginRo + ): Promise { + return this.dashboardService.installPlugin(baseId, id, ro); + } + + @Delete(':id/plugin/:pluginInstallId') + @Permissions('base|update') + removePlugin( + @Param('baseId') baseId: string, + @Param('id') id: string, + @Param('pluginInstallId') pluginInstallId: string + ): Promise { + return this.dashboardService.removePlugin(baseId, id, pluginInstallId); + } + + @Patch(':id/plugin/:pluginInstallId/rename') + @Permissions('base|update') + renamePlugin( + @Param('baseId') baseId: string, + @Param('id') id: string, + @Param('pluginInstallId') pluginInstallId: string, + @Body(new ZodValidationPipe(renameDashboardRoSchema)) ro: IRenameDashboardRo + ): Promise { + return this.dashboardService.renamePlugin(baseId, id, pluginInstallId, ro.name); + } + + @Patch(':id/plugin/:pluginInstallId/update-storage') + @Permissions('base|update') + updatePluginStorage( + @Param('baseId') baseId: string, + @Param('id') id: string, + @Param('pluginInstallId') pluginInstallId: string, + @Body(new ZodValidationPipe(dashboardPluginUpdateStorageRoSchema)) + ro: IDashboardPluginUpdateStorageRo + ): Promise { + return this.dashboardService.updatePluginStorage(baseId, id, pluginInstallId, ro.storage); + } + + @Get(':id/plugin/:pluginInstallId') + @Permissions('base|read') + getPluginInstall( + @Param('baseId') baseId: string, + @Param('id') id: string, + @Param('pluginInstallId') pluginInstallId: string + ): Promise { + return this.dashboardService.getPluginInstall(baseId, id, pluginInstallId); + } +} diff --git a/apps/nestjs-backend/src/features/dashboard/dashboard.module.ts b/apps/nestjs-backend/src/features/dashboard/dashboard.module.ts new file mode 100644 index 000000000..2582f2088 --- /dev/null +++ b/apps/nestjs-backend/src/features/dashboard/dashboard.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { DashboardController } from './dashboard.controller'; +import { DashboardService } from './dashboard.service'; + +@Module({ + providers: [DashboardService], + controllers: [DashboardController], +}) +export class DashboardModule {} diff --git a/apps/nestjs-backend/src/features/dashboard/dashboard.service.spec.ts b/apps/nestjs-backend/src/features/dashboard/dashboard.service.spec.ts new file mode 100644 index 000000000..bef15c765 --- /dev/null +++ b/apps/nestjs-backend/src/features/dashboard/dashboard.service.spec.ts @@ -0,0 +1,21 @@ +import type { TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; +import { GlobalModule } from '../../global/global.module'; +import { DashboardModule } from './dashboard.module'; +import { DashboardService } from './dashboard.service'; + +describe('DashboardService', () => { + let service: DashboardService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [GlobalModule, DashboardModule], + }).compile(); + + service = module.get(DashboardService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/apps/nestjs-backend/src/features/dashboard/dashboard.service.ts b/apps/nestjs-backend/src/features/dashboard/dashboard.service.ts new file mode 100644 index 000000000..429cef2b5 --- /dev/null +++ b/apps/nestjs-backend/src/features/dashboard/dashboard.service.ts @@ -0,0 +1,363 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { generateDashboardId, generatePluginInstallId } from '@teable/core'; +import { PrismaService } from '@teable/db-main-prisma'; +import { PluginPosition, PluginStatus } from '@teable/openapi'; +import type { + ICreateDashboardRo, + IDashboardInstallPluginRo, + IGetDashboardInstallPluginVo, + IGetDashboardListVo, + IGetDashboardVo, + IUpdateLayoutDashboardRo, +} from '@teable/openapi'; +import type { IDashboardLayout, IDashboardPluginItem } from '@teable/openapi/src/dashboard/types'; +import { ClsService } from 'nestjs-cls'; +import type { IClsStore } from '../../types/cls'; + +@Injectable() +export class DashboardService { + constructor( + private readonly prismaService: PrismaService, + private readonly cls: ClsService + ) {} + + async getDashboard(baseId: string): Promise { + return this.prismaService.dashboard.findMany({ + where: { + baseId, + }, + select: { + id: true, + name: true, + }, + }); + } + + async getDashboardById(baseId: string, id: string): Promise { + const dashboard = await this.prismaService.dashboard + .findFirstOrThrow({ + where: { + id, + baseId, + }, + select: { + id: true, + name: true, + layout: true, + }, + }) + .catch(() => { + throw new NotFoundException('Dashboard not found'); + }); + + const plugins = await this.prismaService.pluginInstall.findMany({ + where: { + positionId: dashboard.id, + position: PluginPosition.Dashboard, + }, + select: { + id: true, + name: true, + pluginId: true, + plugin: { + select: { + url: true, + }, + }, + }, + }); + + return { + ...dashboard, + layout: dashboard.layout ? JSON.parse(dashboard.layout) : undefined, + pluginMap: plugins.reduce( + (acc, plugin) => { + acc[plugin.id] = { + id: plugin.pluginId, + pluginInstallId: plugin.id, + name: plugin.name, + url: plugin.plugin.url ?? undefined, + }; + return acc; + }, + {} as Record + ), + }; + } + + async createDashboard(baseId: string, dashboard: ICreateDashboardRo) { + const userId = this.cls.get('user.id'); + return this.prismaService.dashboard.create({ + data: { + id: generateDashboardId(), + baseId, + name: dashboard.name, + createdBy: userId, + }, + select: { + id: true, + name: true, + }, + }); + } + + async renameDashboard(baseId: string, id: string, name: string) { + return this.prismaService.dashboard + .update({ + where: { + baseId, + id, + }, + data: { + name, + }, + select: { + id: true, + name: true, + }, + }) + .catch(() => { + throw new NotFoundException('Dashboard not found'); + }); + } + + async updateLayout(baseId: string, id: string, layout: IUpdateLayoutDashboardRo['layout']) { + const ro = await this.prismaService.dashboard + .update({ + where: { + baseId, + id, + }, + data: { + layout: JSON.stringify(layout), + }, + select: { + id: true, + name: true, + layout: true, + }, + }) + .catch(() => { + throw new NotFoundException('Dashboard not found'); + }); + return { + ...ro, + layout: ro.layout ? JSON.parse(ro.layout) : undefined, + }; + } + + async deleteDashboard(baseId: string, id: string) { + await this.prismaService.dashboard + .delete({ + where: { + baseId, + id, + }, + }) + .catch(() => { + throw new NotFoundException('Dashboard not found'); + }); + } + + private async validatePluginPublished(_baseId: string, pluginId: string) { + return this.prismaService.plugin + .findFirstOrThrow({ + where: { + id: pluginId, + status: PluginStatus.Published, + }, + }) + .catch(() => { + throw new NotFoundException('Plugin not found'); + }); + } + + async installPlugin(baseId: string, id: string, ro: IDashboardInstallPluginRo) { + const userId = this.cls.get('user.id'); + await this.validatePluginPublished(baseId, ro.pluginId); + return this.prismaService.$tx(async () => { + const newInstallPlugin = await this.prismaService.txClient().pluginInstall.create({ + data: { + id: generatePluginInstallId(), + baseId, + positionId: id, + position: PluginPosition.Dashboard, + name: ro.name, + pluginId: ro.pluginId, + createdBy: userId, + }, + select: { + id: true, + name: true, + pluginId: true, + }, + }); + const dashboard = await this.prismaService.txClient().dashboard.findFirstOrThrow({ + where: { + id, + baseId, + }, + select: { + layout: true, + }, + }); + const layout = dashboard.layout ? (JSON.parse(dashboard.layout) as IDashboardLayout) : []; + layout.push({ + pluginInstallId: newInstallPlugin.id, + x: (layout.length * 2) % 12, + y: Number.MAX_SAFE_INTEGER, // puts it at the bottom + w: 2, + h: 2, + }); + await this.prismaService.txClient().dashboard.update({ + where: { + id, + }, + data: { + layout: JSON.stringify(layout), + }, + }); + return { + id, + pluginId: newInstallPlugin.pluginId, + pluginInstallId: newInstallPlugin.id, + name: ro.name, + }; + }); + } + + private async validateDashboard(baseId: string, dashboardId: string) { + await this.prismaService + .txClient() + .dashboard.findFirstOrThrow({ + where: { + baseId, + id: dashboardId, + }, + }) + .catch(() => { + throw new NotFoundException('Dashboard not found'); + }); + } + + async removePlugin(baseId: string, dashboardId: string, pluginInstallId: string) { + return this.prismaService.$tx(async () => { + await this.prismaService + .txClient() + .pluginInstall.delete({ + where: { + id: pluginInstallId, + baseId, + positionId: dashboardId, + plugin: { + status: PluginStatus.Published, + }, + }, + }) + .catch(() => { + throw new NotFoundException('Plugin not found'); + }); + const dashboard = await this.prismaService.txClient().dashboard.findFirstOrThrow({ + where: { + id: dashboardId, + baseId, + }, + select: { + layout: true, + }, + }); + const layout = dashboard.layout ? (JSON.parse(dashboard.layout) as IDashboardLayout) : []; + const index = layout.findIndex((item) => item.pluginInstallId === pluginInstallId); + if (index !== -1) { + layout.splice(index, 1); + await this.prismaService.txClient().dashboard.update({ + where: { + id: dashboardId, + }, + data: { + layout: JSON.stringify(layout), + }, + }); + } + }); + } + + private async validateAndGetPluginInstall(pluginInstallId: string) { + return this.prismaService + .txClient() + .pluginInstall.findFirstOrThrow({ + where: { + id: pluginInstallId, + plugin: { + status: PluginStatus.Published, + }, + }, + }) + .catch(() => { + throw new NotFoundException('Plugin not found'); + }); + } + + async renamePlugin(baseId: string, dashboardId: string, pluginInstallId: string, name: string) { + return this.prismaService.$tx(async () => { + await this.validateDashboard(baseId, dashboardId); + const plugin = await this.validateAndGetPluginInstall(pluginInstallId); + await this.prismaService.txClient().pluginInstall.update({ + where: { + id: pluginInstallId, + }, + data: { + name, + }, + }); + return { + id: plugin.pluginId, + pluginInstallId, + name, + }; + }); + } + + async updatePluginStorage( + baseId: string, + dashboardId: string, + pluginInstallId: string, + storage?: Record + ) { + return this.prismaService.$tx(async () => { + await this.validateDashboard(baseId, dashboardId); + await this.validateAndGetPluginInstall(pluginInstallId); + const res = await this.prismaService.txClient().pluginInstall.update({ + where: { + id: pluginInstallId, + }, + data: { + storage: storage ? JSON.stringify(storage) : null, + }, + }); + return { + baseId, + dashboardId, + pluginInstallId: res.id, + storage: res.storage ? JSON.parse(res.storage) : undefined, + }; + }); + } + + async getPluginInstall( + baseId: string, + dashboardId: string, + pluginInstallId: string + ): Promise { + await this.validateDashboard(baseId, dashboardId); + const plugin = await this.validateAndGetPluginInstall(pluginInstallId); + return { + name: plugin.name, + baseId: plugin.baseId, + pluginId: plugin.pluginId, + pluginInstallId: plugin.id, + storage: plugin.storage ? JSON.parse(plugin.storage) : undefined, + }; + } +} diff --git a/apps/nestjs-backend/src/features/next/next.controller.ts b/apps/nestjs-backend/src/features/next/next.controller.ts index 96e8c4f3e..6679091bf 100644 --- a/apps/nestjs-backend/src/features/next/next.controller.ts +++ b/apps/nestjs-backend/src/features/next/next.controller.ts @@ -31,7 +31,6 @@ export class NextController { 'oauth/?*', 'developer/?*', 'public/?*', - 'plugin/?*', ]) public async home(@Req() req: express.Request, @Res() res: express.Response) { await this.nextService.server.getRequestHandler()(req, res); diff --git a/apps/nestjs-backend/src/features/next/next.module.ts b/apps/nestjs-backend/src/features/next/next.module.ts index 510f5fd4d..363cfb927 100644 --- a/apps/nestjs-backend/src/features/next/next.module.ts +++ b/apps/nestjs-backend/src/features/next/next.module.ts @@ -1,8 +1,9 @@ import { Module } from '@nestjs/common'; import { NextController } from './next.controller'; import { NextService } from './next.service'; - +import { NextPluginModule } from './plugin/plugin.module'; @Module({ + imports: [NextPluginModule], providers: [NextService], controllers: [NextController], }) diff --git a/apps/nestjs-backend/src/features/next/plugin/plugin-proxy.middleware.ts b/apps/nestjs-backend/src/features/next/plugin/plugin-proxy.middleware.ts new file mode 100644 index 000000000..31f86ccba --- /dev/null +++ b/apps/nestjs-backend/src/features/next/plugin/plugin-proxy.middleware.ts @@ -0,0 +1,21 @@ +// proxy.middleware.ts +import type { NestMiddleware } from '@nestjs/common'; +import type { Request, Response } from 'express'; +import type { RequestHandler } from 'http-proxy-middleware'; +import { createProxyMiddleware } from 'http-proxy-middleware'; +import { BaseConfig, IBaseConfig } from '../../../configs/base.config'; + +export class PluginProxyMiddleware implements NestMiddleware { + private proxy: RequestHandler; + + constructor(@BaseConfig() private readonly baseConfig: IBaseConfig) { + this.proxy = createProxyMiddleware({ + target: `http://localhost:${baseConfig.pluginServerPort}`, + }); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async use(req: Request, res: Response, next: () => void): Promise { + this.proxy(req, res, next); + } +} diff --git a/apps/nestjs-backend/src/features/next/plugin/plugin-proxy.module.ts b/apps/nestjs-backend/src/features/next/plugin/plugin-proxy.module.ts new file mode 100644 index 000000000..3d46b8273 --- /dev/null +++ b/apps/nestjs-backend/src/features/next/plugin/plugin-proxy.module.ts @@ -0,0 +1,16 @@ +import type { MiddlewareConsumer, NestModule } from '@nestjs/common'; +import { Module, RequestMethod } from '@nestjs/common'; +import { PluginProxyMiddleware } from './plugin-proxy.middleware'; +@Module({ + providers: [], + imports: [], +}) +export class PluginProxyModule implements NestModule { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + configure(consumer: MiddlewareConsumer): any { + consumer.apply(PluginProxyMiddleware).forRoutes({ + method: RequestMethod.ALL, + path: 'plugin/?*', + }); + } +} diff --git a/apps/nestjs-backend/src/features/next/plugin/plugin.module.ts b/apps/nestjs-backend/src/features/next/plugin/plugin.module.ts new file mode 100644 index 000000000..e0abb3e59 --- /dev/null +++ b/apps/nestjs-backend/src/features/next/plugin/plugin.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { PluginProxyModule } from './plugin-proxy.module'; +@Module({ + imports: [PluginProxyModule], + providers: [], + controllers: [], +}) +export class NextPluginModule {} diff --git a/apps/nestjs-backend/src/features/plugin/official/config/chart.ts b/apps/nestjs-backend/src/features/plugin/official/config/chart.ts new file mode 100644 index 000000000..9eb4a6614 --- /dev/null +++ b/apps/nestjs-backend/src/features/plugin/official/config/chart.ts @@ -0,0 +1,32 @@ +export const chartConfig = { + id: 'plgRuucVT5FikQSIyC9', + name: 'Chart', + description: 'Visualize your records on a bar, line, pie', + detailDesc: ` + If you're looking for a colorful way to get a big-picture overview of a table, try a chart app. + + + + The chart app summarizes a table of records and turns it into an interactive bar, line, pie. Make your chart pop by choosing from a set of colors, or color-code the chart to match your records' associated single select fields. + + + + When you need to drill down into your records, clicking on any bar or point on your chart will bring up the associated record or records. + + + Learn more](https://teable.io)", + + `, + helpUrl: 'https://teable.io', + positions: ['dashboard'], + i18n: { + zh: { + name: '图表', + helpUrl: 'https://teable.cn', + description: '通过柱状图、折线图、饼图可视化您的记录', + detailDesc: + '如果您想通过色彩丰富的方式从大局上了解表格,试试图表应用。\n\n图表应用汇总表格记录,并将其转换为交互式的柱状图、折线图、饼图。通过选择一组颜色让您的图表更引人注目,或根据记录的单选字段为图表添加颜色编码。\n\n当您需要深入了解记录时,点击图表上的任何柱状或点状部分,即可显示相关记录或记录详情。\n\n[了解更多](https://teable.cn)', + }, + }, + logoPath: 'static/plugin/chart.png', +}; diff --git a/apps/nestjs-backend/src/features/plugin/official/official-plugin-init.service.ts b/apps/nestjs-backend/src/features/plugin/official/official-plugin-init.service.ts new file mode 100644 index 000000000..5bc501611 --- /dev/null +++ b/apps/nestjs-backend/src/features/plugin/official/official-plugin-init.service.ts @@ -0,0 +1,165 @@ +import { join, resolve } from 'path'; +import { Injectable, Logger, type OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { generatePluginUserId, getPluginEmail } from '@teable/core'; +import { PrismaService } from '@teable/db-main-prisma'; +import { PluginStatus, UploadType } from '@teable/openapi'; +import { createReadStream } from 'fs-extra'; +import { Knex } from 'knex'; +import { InjectModel } from 'nest-knexjs'; +import sharp from 'sharp'; +import { BaseConfig, IBaseConfig } from '../../../configs/base.config'; +import StorageAdapter from '../../attachments/plugins/adapter'; +import { InjectStorageAdapter } from '../../attachments/plugins/storage'; +import { UserService } from '../../user/user.service'; +import { generateSecret } from '../utils'; +import { chartConfig } from './config/chart'; + +@Injectable() +export class OfficialPluginInitService implements OnModuleInit { + private logger = new Logger(OfficialPluginInitService.name); + + constructor( + private readonly prismaService: PrismaService, + private readonly userService: UserService, + private readonly configService: ConfigService, + @InjectStorageAdapter() readonly storageAdapter: StorageAdapter, + @BaseConfig() private readonly baseConfig: IBaseConfig, + @InjectModel('CUSTOM_KNEX') private readonly knex: Knex + ) {} + + // init official plugins + async onModuleInit() { + const officialPlugins = [ + { + ...chartConfig, + secret: this.configService.get('PLUGIN_CHART_SECRET') || this.baseConfig.secretKey, + url: `${this.baseConfig.publicOrigin}/plugin/chart`, + }, + ]; + + try { + await this.prismaService.$tx(async () => { + for (const plugin of officialPlugins) { + this.logger.log(`Creating official plugin: ${plugin.name}`); + await this.createOfficialPlugin(plugin); + } + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (error: any) { + if (error.code !== 'P2002') { + throw error; + } + } + } + + async uploadLogo(id: string, filePath: string) { + const fileStream = createReadStream(resolve(process.cwd(), filePath)); + const metaReader = sharp(); + const sharpReader = fileStream.pipe(metaReader); + const { width, height, format = 'png', size = 0 } = await sharpReader.metadata(); + const path = join(StorageAdapter.getDir(UploadType.Plugin), id); + const bucket = StorageAdapter.getBucket(UploadType.Plugin); + const mimetype = `image/${format}`; + const { hash } = await this.storageAdapter.uploadFileWidthPath(bucket, path, filePath, { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'Content-Type': mimetype, + }); + await this.prismaService.txClient().attachments.upsert({ + create: { + token: id, + path, + size, + width, + height, + hash, + mimetype, + createdBy: 'system', + }, + update: { + size, + width, + height, + hash, + mimetype, + lastModifiedBy: 'system', + }, + where: { + token: id, + deletedTime: null, + }, + }); + return `/${path}/${id}`; + } + + async createOfficialPlugin(pluginConfig: typeof chartConfig & { secret: string; url: string }) { + const { + id: pluginId, + name, + description, + detailDesc, + logoPath, + i18n, + positions, + helpUrl, + secret, + url, + } = pluginConfig; + + const rows = await this.prismaService.txClient().plugin.count({ where: { id: pluginId } }); + + if (rows > 0) { + const { hashedSecret, maskedSecret } = await generateSecret(secret); + return this.prismaService.txClient().plugin.update({ + where: { + id: pluginId, + }, + data: { + secret: hashedSecret, + maskedSecret, + }, + }); + } + // upload logo + const logo = await this.uploadLogo(pluginId, logoPath); + const pluginUserId = generatePluginUserId(); + const user = await this.userService.createSystemUser({ + id: pluginUserId, + name, + email: getPluginEmail(pluginId), + }); + const { hashedSecret, maskedSecret } = await generateSecret(secret); + return this.prismaService.txClient().plugin.create({ + select: { + id: true, + name: true, + description: true, + detailDesc: true, + positions: true, + helpUrl: true, + logo: true, + url: true, + status: true, + i18n: true, + secret: true, + createdTime: true, + }, + data: { + id: pluginId, + name, + description, + detailDesc, + positions: JSON.stringify(positions), + helpUrl, + url, + logo, + status: PluginStatus.Published, + i18n: JSON.stringify(i18n), + secret: hashedSecret, + maskedSecret, + pluginUser: user.id, + createdBy: 'system', + }, + }); + } +} diff --git a/apps/nestjs-backend/src/features/plugin/plugin-auth.service.ts b/apps/nestjs-backend/src/features/plugin/plugin-auth.service.ts new file mode 100644 index 000000000..db7937a56 --- /dev/null +++ b/apps/nestjs-backend/src/features/plugin/plugin-auth.service.ts @@ -0,0 +1,191 @@ +import { + BadRequestException, + Injectable, + InternalServerErrorException, + NotFoundException, +} from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { getRandomString } from '@teable/core'; +import { PrismaService } from '@teable/db-main-prisma'; +import { + PluginStatus, + type IPluginGetTokenRo, + type IPluginGetTokenVo, + type IPluginRefreshTokenRo, + type IPluginRefreshTokenVo, +} from '@teable/openapi'; +import { CacheService } from '../../cache/cache.service'; +import { second } from '../../utils/second'; +import { AccessTokenService } from '../access-token/access-token.service'; +import { validateSecret } from './utils'; + +interface IRefreshPayload { + pluginId: string; + secret: string; + accessTokenId: string; +} + +@Injectable() +export class PluginAuthService { + accessTokenExpireIn = second('10m'); + refreshTokenExpireIn = second('30d'); + + constructor( + private readonly prismaService: PrismaService, + private readonly cacheService: CacheService, + private readonly accessTokenService: AccessTokenService, + private readonly jwtService: JwtService + ) {} + + private generateAccessToken({ + userId, + scopes, + clientId, + name, + baseId, + }: { + userId: string; + scopes: string[]; + clientId: string; + name: string; + baseId: string; + }) { + return this.accessTokenService.createAccessToken({ + clientId, + name: `plugin:${name}`, + scopes, + userId, + baseIds: [baseId], + // 10 minutes + expiredTime: new Date(Date.now() + this.accessTokenExpireIn * 1000).toISOString(), + }); + } + + private async generateRefreshToken({ pluginId, secret, accessTokenId }: IRefreshPayload) { + return this.jwtService.signAsync( + { + secret, + accessTokenId, + pluginId, + }, + { expiresIn: this.refreshTokenExpireIn } + ); + } + + private async validateSecret(secret: string, pluginId: string) { + const plugin = await this.prismaService.plugin + .findFirstOrThrow({ + where: { id: pluginId, status: PluginStatus.Published }, + }) + .catch(() => { + throw new NotFoundException('Plugin not found'); + }); + if (!plugin.pluginUser) { + throw new BadRequestException('Plugin user not found'); + } + const checkSecret = await validateSecret(secret, plugin.secret); + if (!checkSecret) { + throw new BadRequestException('Invalid secret'); + } + return { + ...plugin, + pluginUser: plugin.pluginUser, + }; + } + + async token(pluginId: string, ro: IPluginGetTokenRo): Promise { + const { secret, scopes, baseId } = ro; + const plugin = await this.validateSecret(secret, pluginId); + + const accessToken = await this.generateAccessToken({ + userId: plugin.pluginUser, + scopes, + baseId, + clientId: pluginId, + name: plugin.name, + }); + + const refreshToken = await this.generateRefreshToken({ + pluginId, + secret, + accessTokenId: accessToken.id, + }); + + return { + accessToken: accessToken.token, + refreshToken, + scopes, + expiresIn: this.accessTokenExpireIn, + refreshExpiresIn: this.refreshTokenExpireIn, + }; + } + + async refreshToken(pluginId: string, ro: IPluginRefreshTokenRo): Promise { + const { secret, refreshToken } = ro; + const plugin = await this.validateSecret(secret, pluginId); + const payload = await this.jwtService.verifyAsync(refreshToken).catch(() => { + // eslint-disable-next-line sonarjs/no-duplicate-string + throw new BadRequestException('Invalid refresh token'); + }); + + if ( + payload.pluginId !== pluginId || + payload.secret !== secret || + payload.accessTokenId === undefined + ) { + throw new BadRequestException('Invalid refresh token'); + } + return this.prismaService.$tx(async (prisma) => { + const oldAccessToken = await prisma.accessToken + .findFirstOrThrow({ + where: { id: payload.accessTokenId }, + }) + .catch(() => { + throw new BadRequestException('Invalid refresh token'); + }); + + await prisma.accessToken.delete({ + where: { id: payload.accessTokenId, userId: plugin.pluginUser }, + }); + + const baseId = oldAccessToken.baseIds ? JSON.parse(oldAccessToken.baseIds)[0] : ''; + const scopes = oldAccessToken.scopes ? JSON.parse(oldAccessToken.scopes) : []; + if (!baseId) { + throw new InternalServerErrorException('Anomalous token with no baseId'); + } + + const accessToken = await this.generateAccessToken({ + userId: plugin.pluginUser, + scopes, + baseId, + clientId: pluginId, + name: plugin.name, + }); + + const refreshToken = await this.generateRefreshToken({ + pluginId, + secret, + accessTokenId: accessToken.id, + }); + return { + accessToken: accessToken.token, + refreshToken, + scopes, + expiresIn: this.accessTokenExpireIn, + refreshExpiresIn: this.refreshTokenExpireIn, + }; + }); + } + + async authCode(pluginId: string, baseId: string) { + const count = await this.prismaService.pluginInstall.count({ + where: { pluginId, baseId }, + }); + if (count === 0) { + throw new NotFoundException('Plugin not installed'); + } + const authCode = getRandomString(16); + await this.cacheService.set(`plugin:auth-code:${authCode}`, { baseId, pluginId }, second('5m')); + return authCode; + } +} diff --git a/apps/nestjs-backend/src/features/plugin/plugin.controller.spec.ts b/apps/nestjs-backend/src/features/plugin/plugin.controller.spec.ts new file mode 100644 index 000000000..ddc498f8e --- /dev/null +++ b/apps/nestjs-backend/src/features/plugin/plugin.controller.spec.ts @@ -0,0 +1,19 @@ +import type { TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; +import { PluginController } from './plugin.controller'; + +describe('PluginController', () => { + let controller: PluginController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [PluginController], + }).compile(); + + controller = module.get(PluginController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/apps/nestjs-backend/src/features/plugin/plugin.controller.ts b/apps/nestjs-backend/src/features/plugin/plugin.controller.ts new file mode 100644 index 000000000..985c65e1b --- /dev/null +++ b/apps/nestjs-backend/src/features/plugin/plugin.controller.ts @@ -0,0 +1,109 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common'; +import type { + ICreatePluginVo, + IGetPluginCenterListVo, + IGetPluginsVo, + IGetPluginVo, + IPluginGetTokenVo, + IPluginRefreshTokenVo, + IPluginRegenerateSecretVo, + IUpdatePluginVo, +} from '@teable/openapi'; +import { + createPluginRoSchema, + ICreatePluginRo, + updatePluginRoSchema, + IUpdatePluginRo, + getPluginCenterListRoSchema, + IGetPluginCenterListRo, + pluginGetTokenRoSchema, + IPluginGetTokenRo, + pluginRefreshTokenRoSchema, + IPluginRefreshTokenRo, +} from '@teable/openapi'; +import { ZodValidationPipe } from '../../zod.validation.pipe'; +import { Permissions } from '../auth/decorators/permissions.decorator'; +import { Public } from '../auth/decorators/public.decorator'; +import { ResourceMeta } from '../auth/decorators/resource_meta.decorator'; +import { PluginAuthService } from './plugin-auth.service'; +import { PluginService } from './plugin.service'; + +@Controller('api/plugin') +export class PluginController { + constructor( + private readonly pluginService: PluginService, + private readonly pluginAuthService: PluginAuthService + ) {} + + @Post() + createPlugin( + @Body(new ZodValidationPipe(createPluginRoSchema)) data: ICreatePluginRo + ): Promise { + return this.pluginService.createPlugin(data); + } + + @Get() + getPlugins(): Promise { + return this.pluginService.getPlugins(); + } + + @Get(':pluginId') + getPlugin(@Param('pluginId') pluginId: string): Promise { + return this.pluginService.getPlugin(pluginId); + } + + @Post(':pluginId/regenerate-secret') + regenerateSecret(@Param('pluginId') pluginId: string): Promise { + return this.pluginService.regenerateSecret(pluginId); + } + + @Put(':pluginId') + updatePlugin( + @Param('pluginId') pluginId: string, + @Body(new ZodValidationPipe(updatePluginRoSchema)) ro: IUpdatePluginRo + ): Promise { + return this.pluginService.updatePlugin(pluginId, ro); + } + + @Delete(':pluginId') + deletePlugin(@Param('pluginId') pluginId: string): Promise { + return this.pluginService.delete(pluginId); + } + + @Get('center/list') + getPluginCenterList( + @Query(new ZodValidationPipe(getPluginCenterListRoSchema)) ro: IGetPluginCenterListRo + ): Promise { + return this.pluginService.getPluginCenterList(ro.positions); + } + + @Patch(':pluginId/submit') + submitPlugin(@Param('pluginId') pluginId: string): Promise { + return this.pluginService.submitPlugin(pluginId); + } + + @Post(':pluginId/token') + @Public() + accessToken( + @Param('pluginId') pluginId: string, + @Body(new ZodValidationPipe(pluginGetTokenRoSchema)) ro: IPluginGetTokenRo + ): Promise { + return this.pluginAuthService.token(pluginId, ro); + } + + @Post(':pluginId/refreshToken') + @Public() + refreshToken( + @Param('pluginId') pluginId: string, + @Body(new ZodValidationPipe(pluginRefreshTokenRoSchema)) ro: IPluginRefreshTokenRo + ): Promise { + return this.pluginAuthService.refreshToken(pluginId, ro); + } + + @Post(':pluginId/authCode') + @Permissions('base|read') + @ResourceMeta('baseId', 'body') + authCode(@Param('pluginId') pluginId: string, @Body('baseId') baseId: string): Promise { + return this.pluginAuthService.authCode(pluginId, baseId); + } +} diff --git a/apps/nestjs-backend/src/features/plugin/plugin.module.ts b/apps/nestjs-backend/src/features/plugin/plugin.module.ts new file mode 100644 index 000000000..a3f03f10f --- /dev/null +++ b/apps/nestjs-backend/src/features/plugin/plugin.module.ts @@ -0,0 +1,30 @@ +import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { authConfig, type IAuthConfig } from '../../configs/auth.config'; +import { AccessTokenModule } from '../access-token/access-token.module'; +import { StorageModule } from '../attachments/plugins/storage.module'; +import { UserModule } from '../user/user.module'; +import { OfficialPluginInitService } from './official/official-plugin-init.service'; +import { PluginAuthService } from './plugin-auth.service'; +import { PluginController } from './plugin.controller'; +import { PluginService } from './plugin.service'; + +@Module({ + imports: [ + UserModule, + AccessTokenModule, + StorageModule, + JwtModule.registerAsync({ + useFactory: (config: IAuthConfig) => ({ + secret: config.jwt.secret, + signOptions: { + expiresIn: config.jwt.expiresIn, + }, + }), + inject: [authConfig.KEY], + }), + ], + providers: [PluginService, PluginAuthService, OfficialPluginInitService], + controllers: [PluginController], +}) +export class PluginModule {} diff --git a/apps/nestjs-backend/src/features/plugin/plugin.service.spec.ts b/apps/nestjs-backend/src/features/plugin/plugin.service.spec.ts new file mode 100644 index 000000000..e76833556 --- /dev/null +++ b/apps/nestjs-backend/src/features/plugin/plugin.service.spec.ts @@ -0,0 +1,21 @@ +import type { TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; +import { GlobalModule } from '../../global/global.module'; +import { PluginModule } from './plugin.module'; +import { PluginService } from './plugin.service'; + +describe('PluginService', () => { + let service: PluginService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [GlobalModule, PluginModule], + }).compile(); + + service = module.get(PluginService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/apps/nestjs-backend/src/features/plugin/plugin.service.ts b/apps/nestjs-backend/src/features/plugin/plugin.service.ts new file mode 100644 index 000000000..9d9486a78 --- /dev/null +++ b/apps/nestjs-backend/src/features/plugin/plugin.service.ts @@ -0,0 +1,361 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { + generatePluginId, + generatePluginUserId, + getPluginEmail, + nullsToUndefined, +} from '@teable/core'; +import { PrismaService } from '@teable/db-main-prisma'; +import { UploadType, PluginStatus } from '@teable/openapi'; +import type { + IGetPluginCenterListVo, + ICreatePluginRo, + ICreatePluginVo, + IGetPluginsVo, + IGetPluginVo, + IPluginI18n, + IPluginRegenerateSecretVo, + IUpdatePluginRo, + IUpdatePluginVo, + PluginPosition, +} from '@teable/openapi'; +import { omit } from 'lodash'; +import { ClsService } from 'nestjs-cls'; +import type { IClsStore } from '../../types/cls'; +import StorageAdapter from '../attachments/plugins/adapter'; +import { getFullStorageUrl } from '../attachments/plugins/utils'; +import { UserService } from '../user/user.service'; +import { generateSecret } from './utils'; + +@Injectable() +export class PluginService { + constructor( + private readonly prismaService: PrismaService, + private readonly cls: ClsService, + private readonly userService: UserService + ) {} + + private logoToVoValue(logo: string) { + return getFullStorageUrl(StorageAdapter.getBucket(UploadType.Plugin), logo); + } + + private convertToVo< + T extends { + positions: string; + i18n?: string | null; + status: string; + logo: string; + createdTime?: Date | null; + lastModifiedTime?: Date | null; + }, + >(ro: T) { + return nullsToUndefined({ + ...ro, + logo: this.logoToVoValue(ro.logo), + status: ro.status as PluginStatus, + positions: JSON.parse(ro.positions) as PluginPosition[], + i18n: ro.i18n ? (JSON.parse(ro.i18n) as IPluginI18n) : undefined, + createdTime: ro.createdTime?.toISOString(), + lastModifiedTime: ro.lastModifiedTime?.toISOString(), + }); + } + + private async getUserMap(userIds: string[]) { + const users = await this.prismaService.txClient().user.findMany({ + where: { id: { in: userIds }, deletedTime: null }, + select: { + id: true, + name: true, + email: true, + avatar: true, + }, + }); + const systemUser = userIds.find((id) => id === 'system') + ? { + id: 'system', + name: 'Teable', + email: 'support@teable.io', + avatar: undefined, + } + : undefined; + + const userMap = users.reduce( + (acc, user) => { + if (user.id === 'system') { + acc[user.id] = { + id: user.id, + name: 'Teable', + email: 'support@teable.io', + avatar: undefined, + }; + return acc; + } + acc[user.id] = { + ...user, + avatar: user.avatar + ? getFullStorageUrl(StorageAdapter.getBucket(UploadType.Avatar), user.avatar) + : undefined, + }; + return acc; + }, + {} as Record + ); + + return systemUser + ? { + ...userMap, + system: systemUser, + } + : userMap; + } + + async createPlugin(createPluginRo: ICreatePluginRo): Promise { + const userId = this.cls.get('user.id'); + const { name, description, detailDesc, helpUrl, logo, i18n, positions, url } = createPluginRo; + const { secret, hashedSecret, maskedSecret } = await generateSecret(); + const res = await this.prismaService.$tx(async (prisma) => { + const pluginId = generatePluginId(); + const pluginUserId = generatePluginUserId(); + const user = await this.userService.createSystemUser({ + id: pluginUserId, + name, + email: getPluginEmail(pluginId), + }); + const plugin = await prisma.plugin.create({ + select: { + id: true, + name: true, + description: true, + detailDesc: true, + positions: true, + helpUrl: true, + logo: true, + url: true, + status: true, + i18n: true, + secret: true, + createdTime: true, + }, + data: { + id: pluginId, + name, + description, + detailDesc, + positions: JSON.stringify(positions), + helpUrl, + url, + logo, + status: PluginStatus.Developing, + i18n: JSON.stringify(i18n), + secret: hashedSecret, + maskedSecret, + pluginUser: user.id, + createdBy: userId, + }, + }); + return { + ...plugin, + secret, + pluginUser: { + id: user.id, + name: user.name, + email: user.email, + avatar: user.avatar + ? getFullStorageUrl(StorageAdapter.getBucket(UploadType.Avatar), user.avatar) + : undefined, + }, + }; + }); + return this.convertToVo(res); + } + + async updatePlugin(id: string, updatePluginRo: IUpdatePluginRo): Promise { + const userId = this.cls.get('user.id'); + const isAdmin = this.cls.get('user.isAdmin'); + const { name, description, detailDesc, helpUrl, logo, i18n, positions, url } = updatePluginRo; + const res = await this.prismaService.$tx(async (prisma) => { + const res = await prisma.plugin + .update({ + select: { + id: true, + name: true, + description: true, + detailDesc: true, + positions: true, + helpUrl: true, + logo: true, + url: true, + status: true, + i18n: true, + secret: true, + pluginUser: true, + createdTime: true, + lastModifiedTime: true, + }, + where: { id, createdBy: isAdmin ? { in: ['system', userId] } : userId }, + data: { + name, + description, + detailDesc, + positions: JSON.stringify(positions), + helpUrl, + url, + logo, + i18n: JSON.stringify(i18n), + lastModifiedBy: userId, + }, + }) + .catch(() => { + throw new NotFoundException('Plugin not found'); + }); + + if (name && res.pluginUser) { + await this.userService.updateUserName(res.pluginUser, name); + } + return res; + }); + const userMap = res.pluginUser ? await this.getUserMap([res.pluginUser]) : {}; + return this.convertToVo({ + ...res, + pluginUser: res.pluginUser ? userMap[res.pluginUser] : undefined, + }); + } + + async getPlugin(id: string): Promise { + const userId = this.cls.get('user.id'); + const isAdmin = this.cls.get('user.isAdmin'); + const res = await this.prismaService.plugin + .findUniqueOrThrow({ + select: { + id: true, + name: true, + description: true, + detailDesc: true, + positions: true, + helpUrl: true, + logo: true, + url: true, + status: true, + i18n: true, + maskedSecret: true, + pluginUser: true, + createdTime: true, + lastModifiedTime: true, + }, + where: { id, createdBy: isAdmin ? { in: ['system', userId] } : userId }, + }) + .catch(() => { + throw new NotFoundException('Plugin not found'); + }); + const userMap = res.pluginUser ? await this.getUserMap([res.pluginUser]) : {}; + return this.convertToVo({ + ...omit(res, 'maskedSecret'), + secret: res.maskedSecret, + pluginUser: res.pluginUser ? userMap[res.pluginUser] : undefined, + }); + } + + async getPlugins(): Promise { + const userId = this.cls.get('user.id'); + const isAdmin = this.cls.get('user.isAdmin'); + + const res = await this.prismaService.plugin.findMany({ + where: { createdBy: isAdmin ? { in: ['system', userId] } : userId }, + select: { + id: true, + name: true, + description: true, + detailDesc: true, + positions: true, + helpUrl: true, + logo: true, + url: true, + status: true, + i18n: true, + secret: true, + pluginUser: true, + createdTime: true, + lastModifiedTime: true, + }, + }); + const userIds = res.map((r) => r.pluginUser).filter((r) => r !== null) as string[]; + const userMap = await this.getUserMap(userIds); + return res.map((r) => + this.convertToVo({ + ...r, + pluginUser: r.pluginUser ? userMap[r.pluginUser] : undefined, + }) + ); + } + + async delete(id: string) { + await this.prismaService.$tx(async (prisma) => { + const res = await prisma.plugin.delete({ where: { id } }); + if (res.pluginUser) { + await prisma.user.delete({ where: { id: res.pluginUser } }); + } + }); + } + + async regenerateSecret(id: string): Promise { + const { secret, hashedSecret, maskedSecret } = await generateSecret(); + await this.prismaService.plugin.update({ + select: { + id: true, + secret: true, + }, + where: { id }, + data: { + secret: hashedSecret, + maskedSecret, + }, + }); + return { secret, id }; + } + + async getPluginCenterList(positions?: PluginPosition[]): Promise { + const res = await this.prismaService.plugin.findMany({ + select: { + id: true, + name: true, + description: true, + detailDesc: true, + logo: true, + helpUrl: true, + i18n: true, + createdTime: true, + lastModifiedTime: true, + createdBy: true, + }, + where: { + status: PluginStatus.Published, + ...(positions?.length + ? { + OR: positions.map((position) => ({ positions: { contains: position } })), + } + : {}), + }, + }); + const userIds = res.map((r) => r.createdBy); + const userMap = await this.getUserMap(userIds); + return res.map((r) => + nullsToUndefined({ + ...r, + logo: this.logoToVoValue(r.logo), + i18n: r.i18n ? (JSON.parse(r.i18n) as IPluginI18n) : undefined, + createdBy: userMap[r.createdBy], + createdTime: r.createdTime?.toISOString(), + lastModifiedTime: r.lastModifiedTime?.toISOString(), + }) + ); + } + + async submitPlugin(id: string) { + const userId = this.cls.get('user.id'); + await this.prismaService.plugin.update({ + where: { id, createdBy: userId }, + data: { status: PluginStatus.Reviewing }, + }); + } +} diff --git a/apps/nestjs-backend/src/features/plugin/utils.ts b/apps/nestjs-backend/src/features/plugin/utils.ts new file mode 100644 index 000000000..087bba828 --- /dev/null +++ b/apps/nestjs-backend/src/features/plugin/utils.ts @@ -0,0 +1,15 @@ +import { getRandomString } from '@teable/core'; +import * as bcrypt from 'bcrypt'; + +export const generateSecret = async (_secret?: string) => { + const secret = _secret ?? getRandomString(40).toLocaleLowerCase(); + const hashedSecret = await bcrypt.hash(secret, 10); + + const sensitivePart = secret.slice(0, secret.length - 10); + const maskedSecret = secret.slice(0).replace(sensitivePart, '*'.repeat(sensitivePart.length)); + return { secret, hashedSecret, maskedSecret }; +}; + +export const validateSecret = async (secret: string, hashedSecret: string) => { + return bcrypt.compare(secret, hashedSecret); +}; diff --git a/apps/nestjs-backend/src/features/setting/admin.controller.ts b/apps/nestjs-backend/src/features/setting/admin.controller.ts new file mode 100644 index 000000000..ef184741c --- /dev/null +++ b/apps/nestjs-backend/src/features/setting/admin.controller.ts @@ -0,0 +1,14 @@ +import { Controller, Param, Patch, UseGuards } from '@nestjs/common'; +import { AdminGuard } from './admin.guard'; +import { AdminService } from './admin.service'; + +@Controller('api/admin') +export class AdminController { + constructor(private readonly adminService: AdminService) {} + + @UseGuards(AdminGuard) + @Patch('/plugin/:pluginId/publish') + async publishPlugin(@Param('pluginId') pluginId: string): Promise { + await this.adminService.publishPlugin(pluginId); + } +} diff --git a/apps/nestjs-backend/src/features/setting/admin.service.ts b/apps/nestjs-backend/src/features/setting/admin.service.ts new file mode 100644 index 000000000..9a3a16e0e --- /dev/null +++ b/apps/nestjs-backend/src/features/setting/admin.service.ts @@ -0,0 +1,15 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '@teable/db-main-prisma'; +import { PluginStatus } from '@teable/openapi'; + +@Injectable() +export class AdminService { + constructor(private readonly prismaService: PrismaService) {} + + async publishPlugin(pluginId: string) { + return this.prismaService.plugin.update({ + where: { id: pluginId, status: PluginStatus.Reviewing }, + data: { status: PluginStatus.Published }, + }); + } +} diff --git a/apps/nestjs-backend/src/features/setting/setting.module.ts b/apps/nestjs-backend/src/features/setting/setting.module.ts index 0df993dca..2d451ddf1 100644 --- a/apps/nestjs-backend/src/features/setting/setting.module.ts +++ b/apps/nestjs-backend/src/features/setting/setting.module.ts @@ -1,11 +1,13 @@ import { Module } from '@nestjs/common'; +import { AdminController } from './admin.controller'; import { AdminGuard } from './admin.guard'; +import { AdminService } from './admin.service'; import { SettingController } from './setting.controller'; import { SettingService } from './setting.service'; @Module({ - controllers: [SettingController], + controllers: [SettingController, AdminController], exports: [SettingService], - providers: [SettingService, AdminGuard], + providers: [SettingService, AdminGuard, AdminService], }) export class SettingModule {} diff --git a/apps/nestjs-backend/src/features/user/user.service.ts b/apps/nestjs-backend/src/features/user/user.service.ts index b93d778f0..ce0955054 100644 --- a/apps/nestjs-backend/src/features/user/user.service.ts +++ b/apps/nestjs-backend/src/features/user/user.service.ts @@ -118,7 +118,9 @@ export class UserService { notifyMeta: JSON.stringify(defaultNotifyMeta), }; - const userTotalCount = await this.prismaService.txClient().user.count(); + const userTotalCount = await this.prismaService.txClient().user.count({ + where: { isSystem: null }, + }); const isAdmin = !this.baseConfig.isCloud && userTotalCount === 0; @@ -345,4 +347,31 @@ export class UserService { }; }); } + + async createSystemUser({ + id = generateUserId(), + email, + name, + avatar, + }: { + id?: string; + email: string; + name: string; + avatar?: string; + }) { + return this.prismaService.$tx(async () => { + if (!avatar) { + avatar = await this.generateDefaultAvatar(id); + } + return this.prismaService.txClient().user.create({ + data: { + id, + email, + name, + avatar, + isSystem: true, + }, + }); + }); + } } diff --git a/apps/nestjs-backend/static/plugin/chart.png b/apps/nestjs-backend/static/plugin/chart.png new file mode 100644 index 0000000000000000000000000000000000000000..239d5f0b1630c2f07241140bf3f773c4622e8643 GIT binary patch literal 12400 zcmd^lc{tSH`}Z4Zp=cpR6V=$Vl|&4KHpURLg*38e4;j1BB9(n?AIXw6JK3|0l4Y`H z$=D@J!;F0!hIx+9_xJq&`{Vaq&-Gl-T$gLcd7t+=_qp%ay`KB6&ONnbY`km`1RX=E z-@Xq)%-|<8boe0nTJ!4L244rC+YG3ti zvKSMf@PFnLdEqnyf>M>XzW3bY&8}2exYy$_HKb~T=CU^$mKYf4M(`(x0UnZskREe4 zXs^nz?Ir`lyD<(ltYc*R?3MM__rpOms zmDTQrF8WBvq_FGPAqb8dyH34Q-LL$6(;E^(56&&Z9`(L(}n;Az*onpS_(+wRVgn{$We2L$K^x`s3~pxGp3e2#A!Q zjPCj%AjU5kgu-jx?{*M$n6xim9IthF>@EU9-OYZJ-_sN}_YetMct5<%iVZ61=K@p% zJ3jl3bet7}hTg<$uom>^r6ezTgk~(`Tfz-(^C2r%@W|wY_WKPBK_Was>pnpnZ@1a5 zxy5I}DsXLoT?_iFUJjj)K5L}rx>JHMnT@1R)fLCZptGt>P+;gPF`JT}u@qNwgmEBe zy9 z6Os{5s^*11nO^wg^m9P0d@juNFY`o~{%DUI!D8hi zsC_BoeAtJWC2**OX1I1GzsNt^Ste`@=PxRCu_#fFl?6v=T%`&-M4}&!BDQw?3_LWWL zpbi90#icxJUdT1H4X?ZIl9r*=Qh(~Ifh1r^V4@M^My%1`L2Z|1z$|Tv7X~DGT}DUK z0T+x<0`STtdUkjbGQ_@5@rT%jZG^xsE&|@VH3`RI@$_pY?o*G{zBvW9g0Yp%ULn+w zC^mFtwILdc0_>OyDOvk)#5o_jeU}M}l$wcX3Wrc`hS0@y2x^Vbb&f3)!X7PVKF-Gq zsT7i=z6|_WJF#4hv%1IhCX^Wx@TANvxS+808EzDD`DTfKH*q7*bkbgPg-5OhlZ@To zasOwqZRXKZm^w!;IxpY-Z|2UGkUF;WIV*1&2;w;fJ0cZx3W*q43?m^;;CW6gV6Q`AQS>x>-f5(Zo$_L zCQ04^(>cYUtAH;P-lWKeC+K;9bAU7QGmay)(bw2iG|wG401da+*l6PE2t3ETq|7_jKTcuG zXydTAFnGiRK$w%2`z-zD^pCu$y_Y0olNdXCYe31ZDQ=*36)!s&t|=Wg3z`XCk!1?l z*fnq(R9ALlGs+$B%$nJy#|e z7s=KomSyMev4pKO#OQwREYPfNdF`L|<#aKd3H z5oiuq_EaL6T0Hh`tgH2z1?7x<6Prr3F$Gvu*0JlB@25s5yPCWd)PzV3dY2C6jQ7_X zpz6t<9dv}~!*uKi`sj3H1Yq^kLIvw@t1_Yx$qtI_BiS4$_t>^1N84b0l7gb z8t;!I&_lHk4Ik8*1Z+(kIuoYf9|5luz{Zt% zXQmaq^_L1UZ2cFB+$$dQ7vaMnUWEl}-3BHGEt#;rYbw3#PZ7^y|A?g3xNNsniLDjD z#Ez8DSP$nn+-(p_1GXo-kem`jStDJ&KZ~rO5UiuWM1+3Y*gCcH@%O<)!zc40nNz?d zEqD#AmBMSpvEg@@o_nlBtr+fQ_}CrJ8*mKhPyP9UJJ4o-E$$R)|1>Kn@AjgI81C(z z%H3JcV>uh&qgQ?wh#$8@-QcK7*f*Gs+TwK@&dT_{vk3H@{q>;hd6l>i_6EO+u`AEN z1`8Tg;u;Dj05JBIy?TP7qo8wrSF^{tq%@<8+pu8pZ#ctA<{{bCH!UMwa5Uvts%ap1 z;Qm>j_#)T&J$3IW8}T6*gi#+|oPM-zY}C7=dDezT7RK#)>P!}laspndq*LdjyBup) z&wPHz45b!$=k(=V@p+liXlZMAfS)(6h)>hKwTQ=;=V9p{e}0WGX~xVmsy$1?N4$gG zDJJYGUn&Y5V<@icee9=kf)sg+WXzjpV(`~Adg!T;=|fTz!#jPv#=G;OpilWjWew^J zzCVr!98a@BfgAm$YXo@W3B=u7Re2R|n`F;5_#TggM%=w4*5U z^$6SFx(zlT!LiUt@8;~xFx-2mLytRnb4*YUH{u5eE4;q?o36a1&dh(AR=C;Y(!;hS z!@**|zfHv-v&;~y06WULut^q{oaB+&6|r~d(<8;#m0M;!7{=~3;}^*(f|*q;KSd!e zU|qTm{WQpqC`@Qlx~`wsgO50YjuVUGxeUA$EkUTdgIa$-`J5qSLhO4VqqX66=WNp<}3G2*cgc)z|8?%X9HyEr6xS^I{@N|(k^edFPg|Jvtt zZQe8dhzl@!xQ1;-_$ejO#egQ z3jGy1TF^{*-;OkszR1y?2H$Kyb0xn>ywCSEv?FQNq5oOc2(CEn7A4VM!0{x2EP7S6 zHzIy8LNF{MDO2~`d!)B~NAw;&Yx3ivP7T)v+Y-#PqH^znB>9c1iQED@IY((Iumf0= zC&ozY?c3G6`dKhw@VPK@0{rJRTolRmH(RQ%<<}tUreA68`vgOgbCPxohV-(`upcjN z_g@47=dPC9PQrfhV8_LOR`>7l#=wsAZJch99(94{Z^!XP9@|D~k*V~}wc?K~W7TS| zA6n9h1nS!T!f$0wP6L~svElEkS#E-B#Q4~=VZW^JWsy^g=OfrVX`eNVV%KW4_EakK zoEARFcc9~3fPP3c32XblY7IyR> zE#Ixih>$lVmQ)gY`XkC4sDRjZ0eut@BWT)-u zvSOXF^&6%Edha7nq#grku}b!g&1&_yg;?B(&*&bI!o<&9CHUm)ln)I0v!6%1>y#bZ z0M5}t|oa5;Zi*S zx*MKB*ByG2=>uH)|Flm<6o9bmc-X&DtHGXHRysh}>tbwy-;*017t}`Utk8*!7yM;N zp!g#Hx)VcZ{MsDJ`2EL#ALHh^w|?xxU})cfgqhUVh>bC8c7bR=d$LY{UwW&IKezGTdLI2R5LLe=fSkI0dhN|pv*f|9H+EzoLpTH<_T#I4wms)+XUo{G$$O@&} z0R|l<7U?GPQ)`lo`1JpxX*r6U@-i!mB#@Q_YejH?wrw8lJP=>>jT3mnd_v~xXab(UKGxxLf3?l$y@kf#a zU7Om=FA1nkc==ojcG1NsQ|@xZd9d1qo`zaZDk8Rqh%?BICZIP34H3_Y{9z!7iM*V0 zU%|8xgC3IF-zaK58kf6C=a)R_F^ET`sJrW!E8Sy05c#fzXWLJsFa!ufi2rQP_^P(& z3a%^?-eR&y<;;{9S<<91LB*rZvOBvfJ;ahynP|>qV)>8K^#^t{xmme|Or&Oa(o293 zsR+?dqMZy{(nAFUqBW5+GP1O6d#A&{t2(Vc=}7AH=f?nQ8)R3B*T(XX`@~cdaZf4o zjAzFV(5!@TG9bikkX@7pXWfX@Uk2;&|d;P2Is`fn^>1Hni7=A&U+ExjvAw?`pFYuN2=Qu|Vdf zb1o~1t|b{Mmm{Ka82)JGp_3q&lqE(eTWfQND97}1BhbCoT!uw)oSfN8D~+tQqM||S?sEh`!9QY%TS~E#o@$dY!?-$0cdt`WrrHvw_ur>;~PWYH9AZvH>6}Hi59IZ_!8t>r4^X)fdYxcp%tspTjZV zQ`eLWbJ>*e67PThoC*LSfLbZE3q}zgr~h)-cTJ_Iugh%*MQ(I^ax1V45Ag?-UIgS( zb>F}s6+d9@#YJgvGgvHb-+ooQ%e$CvDPPV2*s+m1%~E%^nEHK-;xz-J(1$~ z1NnLKIzH=r=Si)CtYWv=+WRPfE8>_b#cZjg?9f485Y1{udR@bEmod01?@e*tGUO?eKN&jb8RGXqd_HI7YdY}IwgGix^oIH ziasvr(cF0|3=N_gTDQ*Jt;A=Z7ERGkM5l-hdV8b#xZf^5pdRVZC`VNgKSXuBrs_*M8aje zMUruvo6+C-XwEDg0n?-5i72-uzZjRXc>3?Wv}WWwPPHeYr1Z8?!R36D(Kn6$Ry|56 zi4TsOW6S1qFIi3ioqeK;wDv(cV+_ZX*BvNY0eOaU7DNnw^g}4We8*`Cli|Rx&p&~z z)Z`7wkhHJ+8~5atlv=3*(R1cIv`#Go(Fen}F3d07^B2Al$qHCs@ zzRsAwH(#HN1DASs8lU5^oq#vHV7E!~{@ifrAlEMrb^GtT0Vx;}lBQ|x67k?s4l53T zD1j6iYSU$*@yPiqlCkv1sO;?m<&oKL7UkyMfGLo_wjNiWtmUBAZ8y5@RDsxMmdMP1 z-ta-MaM0^>wQ>UF4;G8x=J~GPh_vQ{1TKL<^GtQU5V;tv_0{aL6TxrvOY>2wQPZ-g zxf0cu#xx<)Z|9E-BSfN{`cVW<{lJtF_bd-|)#|d|*KX0Q`fi zqS-L4d&a(!QksLXD|Up5+*WztszhWNR4FzwUd_(6EK40iTx#)H@C|TZDx7m#j7fG zd7vRsNK}tX(eWuiX(meU0Jzd9$m$*#AZhPr4YOUYZ;Tt+Q2!&zT*jHq4BekF%T$^Q zsMcJJ*ME~FjP@H<2-*t}m6@szK;`~Ji6)A1Y;oDrSWLsYA#=nQaM>OUyOIe=LAxTr zobPI4EA>d)lUt7_^a!v0a!gk`I|bPn>`8Y5WkJ+Z5w#ynEExS*%LU?YdiibW`RkGWn z%alcmxlD--Dh~2n5#3RT#kbKPqF^PfRvLdImSf@ZYhf=G7)?I2a?iY(T3OhjCB?a} z)y1dJr{Xn+u49umU5SUbxexxjJ~yp2uQb(Dqu|4{mm9^yv;xXJjxY9;Ia?wEz7q;; z;w+JOKYTY8GvM3!F{V(u@M4e#T#f^;?*##j2&guC4TPfbjQIVOk;q#VN?nkWbM|EX zqsB$4+Yh_P{<1T9gP@}1fbaP^~NFtNQ<^bipyG4 zU@gd9@$~O{m`^6ZmEm=_=(rN5co0-QKnQp@hA+j!6C7P7{?a?G*%zMt4T6>q!B#pV zl>IT>Vkwan#gbBn{CjK)h8NpA`>uYY_t)_7k}o?X0=0@N@5Lzf|8#vmqAX$;52V$s zrZ(-cGd-R4p~M}10X62ZIkJ~+^YEw&j1{V${7;a@c&oN8`w?Wbz!Dvk`(y8*`2|ik z+d7t~{D(93Em)OxzohervWv%v@0$+kY{NX4CBAcxo$Kvi*kQySDks7C=LdG?{Ytk} zhj5C#s_e>n6p(=&Hn}~AHOOOr4za`M8yNv7X8Kxx`D97_=6#HCCa@z7N|o48#mf~; zvy3N?r_8z)5wbkZlel0rOD z6SE#qit0-R8C4lP-#eq_J5sg;$%V%$%*kXI|CF7AMcW>Ep}NsbBh=6ZY}UbC4IlNM zu&?X8A>-zrO&oo|NtTsu3-2AdCb;t|>{y|GP+RreR@ImVx|U_ z#f_k1Pwxnjn&LPglA;ndq|qqK96C64=~72s z;WI)tuC};rR^i^VgA}H=ZL{d0QmWtBD(;bu!hB;<>0(P~)Imci5{2}h5wrfC&i4Bm zacglcVPv}T5~y!YrEB3_Ixj~fF(QjTK@x-iL@}Ctc_PlSV5j^%JqM74!-Ii6*-~Cb`dweo{m7NCe>$uk?Nri520P!2RS1H|Yc9SqZwGh0vrQ5n%wj*9*o60N}pEdz!yi?$bD zwIysl_xlK?UPx9*CuDD0>}M#^XC=M8DPw!xZ_-kf-4=tOrG8Z=&PqPJe4J%bLM6qX}K~<&(!&PGMBgw%X{R&TFnI1*)r&4(c z0lBPpBj48YSM~gG69?nNZb|kur#Mq2$eb&{ja(A%QYDe9__lXjT7iu2(U9}6{u>Z0 zK{bXUjs{ZFKtkj9!|o8ed=;oEy26v9s9W8p_Cs50v1PPs5_d@g#g^^Kp^1GWTOhH~@i*2dVTMvgCEztp6k7lKmD zr#3mUiox1QYkuPv`8l!@rYFQ=_HC9)o^qbD;zfQ@v6n?PQt0=3X!`1(1!J!WxSidI z3S`2`Dlq$>`^JRJtDYzY)Jixh!D4CUQ?25~V-Z%rqw2JZTeNd7XqwR9c)cq7N1ZQp&=Gl z0X6zmw5MV9Di^UcX5NeF?jcDqy4(X!R3%;%YGXIM*BoQLp;y#j|L7sMMam+WsXIUf zl3(f*^|wyCFg;uCU!)~fzX*bjwAx1C(gi=M1aE)r`~(1Ey@c3QnrSFoO5xmJQ$d8DY70uk z#HVLdfWJT^R%7>6fVF<^ucF!IKnUXez26I9LhIdq{o4A=SL*?;c=0VkG#vu9>_}B4 zt^J6`58<}mqXuGT`0^RD--SN@CI^D{245};8%!j`$2gQ4Paf)K6M@eD&b+U>i_M?1 zMO|`oSIq|D_YEE-Y0Q63=B;o7$*@&AxANA zdEyOj?gPW`FX4TBvfG}iP&B(H=aoB*GEK^nUyX8CObl|%*M+yWqz5fTAI87BlGx$5 zAJ3*9*)Nc>Aqc4RW4QqxF=a*KJy+=q>!()j%eNg|9QwC~YE2{}QfB0u1Q`WHXZ0z$ z2aDgPOIVmxhD1Pgy?pE5~K3 zv^xp@+b3?5sSLrU@@_6VfOaDxJ|jvGPK}v+xqYEexq84}TYI5=8)J}%+}$MIw@wHU z9XuBFGd!g@ic=+72daqHmY^0)xI|O*?WVj?Nv3jv%9a-ViG7W|I7a?r`5F_Hu>4O3 zA2)qADEzCbVYg8TydVhG%qIDd$y>lS;~FoyRH;L0o|a(%V1MBQ!^(ata>$zdMPJfv>y#Gndkx z&|aFRy|8NarKH=q_N^S1sli zNHO9mNtXc^Vu*X`XVKWUtv^1f32Iq`%y)Dk*+z^<`$GBgJ>cm+_&n$-kWRro9kr=b zI@insiKao0cRvMr5>5VQDi%<+rAIGUnzEM<6<`-(oXs2uAZjRc@!4b&7j{GL`zK9; zXC+Q>W-dk+xic z9m5jXz|%4UkJWtjwcrNi_?}aPIJ!{Z+RoFjXxvx* z?5lM23T@0BdU63QaE1Iho+Bjb5+B8t$W!yIvRa}L<>`4IJ5hJ8iUlfo3uu%a&7yM$ zz9Ys}(<5x^Z-~$=uP!SOO|ohuXVoObA~~Q)Hns*EugL+E;?1Vcp8}VJMiJPwM@2OY z5)U5ay2egdJkb@z-hfmrbs^dGKgr3adBJtAA2Z~dJ3qtDN3R8D9Mbe@h!qzDvjy21 z?R7pfz5NWtJZs^cSe?xdoM>1tTf=(5==$=|sYmJyxJ5LQ1|s8z)sH|rFW6>(>6^ms z{3zmy6S>+exHvLXw8ZA|^M`FXpM7$#Uhl;4AOu$C=W06Vy9;+}TQXOb^cnZ??X?mS z16NSaZ@DsxBp6%%r`HO;!VWTaZsAs&ev<|R!%9d@CaUY)YISCBJEHnd{9aM*`GY?n zLs9j_b!&2d8GPAfvaV{d;Y9-ZUU(!sDZhwL{@fTH*&a1-4az8MJZSpbDrO~T*z(Ze zNz7Iqp*zp#3W$iVkyDiEJ~h(VaB34{crMJwLW*t1`qHR`Pq>h{9aFfiIW5+ee3*(BqOh}M_8D|v0o%blCB z75-AoV5VdswjhuS(uXkEwZ|VDyQ1bD<2mB>NfW^a@PaFLLFkCK@^2kR@Sf;}jaMfz zCvHBz6CB)9Zs{Ea5^}EJ@ys%P{hManZk1QmH23B`-tTPI?==YT`HYS4wG@;zLi0e| zzL5~KMfhj2`~h1B46rv+?^8&bx5`ynmF()2@+@r+Sh&TT)zvOJxxSlut?+`5Fj9K@ zRZNmTEalq9k)Xmvx?AD3oH|!C&lFm{{X8P&Vy|wAyH#I^f@7t-;I1b{AP_EC!U8?v zxAC#198EY)&nKM-TnFXc`>yL|vmR9-dJPx9)*FQ}Lpr38=skrTbbkI~Frefbw116eqWJxWNZxwY>f#YV2?PlQgLR>){Whk+ zb0BerB5#2bJ>>R=8FY|vfF+>T3;SJh@*w^Fe}D32a51F_0RI6KG{}#5Ln;enC^l9dZuAMNEh|j}!f?k%IVR zlYp_0u2~FEI;!G3c^Z-i-qBq=)c3D2q)BA1`?N!B%CsI z{}z*q?1)?Q1^k{6HZ;_?u1hJJ`y`Z;3JLJtTu&vPFj2Xf2Rn{95V)ZNW^#d^ki!xe zawyyg=KqYmf-iQ_T@p;5OY$NjinJZ0$vSytQ3iI42_y~` zLR%?P{?&eK_Y%g^x4-|R=1bf~`%HZv+#}_0)J!u$BxffBi2k*0!|1DX)H8L8_}Qzs z`hW6Qb=l{Q+d>T+4vBBSnq}P{-EW-|w7mE8(lloi2NTp9S0M`dY< z)1@14VJuK2jH(A|@`u7MlxiaKEZ07eu0qh@&(o|8{I!laR8d6Ir9Wf)9iIY-p)}~z zM3HU{-GsMk&E!1$0Vsb!GJ0mOgrzLd)n~0o$s!os5#RFV3N%MFg?FF4T<=af3%N=JSa0e7~fg3dS zwEyxt97}pEo2dZbq1o|b>_i&uBBSDhw3?&&_wn%cvNcNIdwvEiY- z!ge^+&LM4a>_1w(-f6d-rNUsB(iH@$m+bhxv29KmZ-Wc!QXp7MSAbI`>|D^FcV-tZ z36;KkbONDvmm5wMwbSIHkzo3`Pw>`R{bgBd_U4C8dt-oczXLx}s{3k7RqnRmd784OScw zAuxvgBKxAR+b&PaWvJ!RTBf92M_id{j2CF?b=7n@NwXttPX?{dF+|7`{IzqS<0S}{ z3VY-{(iYKdojGpLhhjC(waq31bI7(Cq~(-EgIHq5wN&pEC^B&XrGG!yeY@vgi5?gha-)7w_ff=hfG*_QZ};A}t|fv1 z22wdy?TkJ-;At@nOoZ0@=#-Js!zD44a(L1K(0A~hyiGHDcttrX17^Q>uX^t|;uL7l zITs&5xYa){7*@xzl_h0H8Xvs6A{6gGoH<*2^)TpZl8k?$InHZz&U;l0^Q9OwG~pD8 ze@16Df|*FzXH~;4*kKZsW?KMFq3)h2ZS^l=BGnreVq*kxEF2K=KlG6$Gg#8C#FjZo SdExs@qVC+gU3k;{#s30Tu$tZg literal 0 HcmV?d00001 diff --git a/apps/nestjs-backend/test/base-query.e2e-spec.ts b/apps/nestjs-backend/test/base-query.e2e-spec.ts index 7183de1a8..fbaa3e20c 100644 --- a/apps/nestjs-backend/test/base-query.e2e-spec.ts +++ b/apps/nestjs-backend/test/base-query.e2e-spec.ts @@ -531,8 +531,6 @@ describe('BaseSqlQuery e2e', () => { { [`${table1.fields[0].id}_${table1.fields[0].name}`]: 'Charlie', [`${table1.fields[1].id}_${table1.fields[1].name}`]: 40, - [`${table2.fields[0].id}_${table2.fields[0].name}`]: null, - [`${table2.fields[1].id}_${table2.fields[1].name}`]: null, }, ]); }); diff --git a/apps/nestjs-backend/test/computed-user-field.e2e-spec.ts b/apps/nestjs-backend/test/computed-user-field.e2e-spec.ts index a31d90664..e7b28ca22 100644 --- a/apps/nestjs-backend/test/computed-user-field.e2e-spec.ts +++ b/apps/nestjs-backend/test/computed-user-field.e2e-spec.ts @@ -21,19 +21,22 @@ import { EventEmitterService } from '../src/event-emitter/event-emitter.service' import { Events } from '../src/event-emitter/events'; import { createNewUserAxios } from './utils/axios-instance/new-user'; import { createAwaitWithEvent } from './utils/event-promise'; -import { createField, createTable, initApp } from './utils/init-app'; +import { createBase, createField, createTable, deleteBase, initApp } from './utils/init-app'; describe('Computed user field (e2e)', () => { let app: INestApplication; - const baseId = globalThis.testConfig.baseId; + const spaceId = globalThis.testConfig.spaceId; const userName = globalThis.testConfig.userName; - + let baseId: string; beforeAll(async () => { const appCtx = await initApp(); app = appCtx.app; + const base = await createBase({ name: 'base1', spaceId }); + baseId = base.id; }); afterAll(async () => { + await deleteBase(baseId); await app.close(); }); diff --git a/apps/nestjs-backend/test/dashboard.e2e-spec.ts b/apps/nestjs-backend/test/dashboard.e2e-spec.ts new file mode 100644 index 000000000..ca15168d4 --- /dev/null +++ b/apps/nestjs-backend/test/dashboard.e2e-spec.ts @@ -0,0 +1,157 @@ +import type { INestApplication } from '@nestjs/common'; +import { + createDashboard, + createDashboardVoSchema, + createPlugin, + dashboardInstallPluginVoSchema, + deleteDashboard, + deletePlugin, + getDashboard, + getDashboardVoSchema, + installPlugin, + PluginPosition, + publishPlugin, + removePlugin, + renameDashboard, + renameDashboardVoSchema, + renamePlugin, + submitPlugin, + updateLayoutDashboard, +} from '@teable/openapi'; +import { getError } from './utils/get-error'; +import { initApp } from './utils/init-app'; + +const dashboardRo = { + name: 'dashboard', +}; + +describe('DashboardController', () => { + let app: INestApplication; + let dashboardId: string; + const baseId = globalThis.testConfig.baseId; + + beforeAll(async () => { + const appCtx = await initApp(); + app = appCtx.app; + }); + + beforeEach(async () => { + const res = await createDashboard(baseId, dashboardRo); + dashboardId = res.data.id; + }); + + afterEach(async () => { + await deleteDashboard(baseId, dashboardId); + }); + + afterAll(async () => { + await app.close(); + }); + + it('/api/dashboard (POST)', async () => { + const res = await createDashboard(baseId, dashboardRo); + expect(createDashboardVoSchema.strict().safeParse(res.data).success).toBe(true); + expect(res.status).toBe(201); + await deleteDashboard(baseId, res.data.id); + }); + + it('/api/dashboard/:id (GET)', async () => { + const getRes = await getDashboard(baseId, dashboardId); + expect(getDashboardVoSchema.strict().safeParse(getRes.data).success).toBe(true); + expect(getRes.data.id).toBe(dashboardId); + }); + + it('/api/dashboard/:id (DELETE)', async () => { + const res = await createDashboard(baseId, dashboardRo); + await deleteDashboard(baseId, res.data.id); + const error = await getError(() => getDashboard(baseId, res.data.id)); + expect(error?.status).toBe(404); + }); + + it('/api/dashboard/:id/rename (PATCH)', async () => { + const res = await createDashboard(baseId, dashboardRo); + const newName = 'new-dashboard'; + const renameRes = await renameDashboard(baseId, res.data.id, newName); + expect(renameRes.data.name).toBe(newName); + await deleteDashboard(baseId, res.data.id); + }); + + it('/api/dashboard/:id/layout (PATCH)', async () => { + const res = await createDashboard(baseId, dashboardRo); + const layout = [{ pluginInstallId: 'plugin-install-id', x: 0, y: 0, w: 1, h: 1 }]; + const updateRes = await updateLayoutDashboard(baseId, res.data.id, layout); + expect(updateRes.data.layout).toEqual(layout); + await deleteDashboard(baseId, res.data.id); + }); + + describe('plugin', () => { + let pluginId: string; + beforeEach(async () => { + const res = await createPlugin({ + name: 'plugin', + logo: 'https://logo.com', + positions: [PluginPosition.Dashboard], + }); + pluginId = res.data.id; + await submitPlugin(pluginId); + await publishPlugin(pluginId); + }); + + afterEach(async () => { + await deletePlugin(pluginId); + }); + + it('/api/dashboard/:id/plugin (POST)', async () => { + const installRes = await installPlugin(baseId, dashboardId, { + name: 'plugin1111', + pluginId, + }); + const dashboard = await getDashboard(baseId, dashboardId); + expect(getDashboardVoSchema.safeParse(dashboard.data).success).toBe(true); + expect(installRes.data.name).toBe('plugin1111'); + expect(dashboardInstallPluginVoSchema.safeParse(installRes.data).success).toBe(true); + }); + + it('/api/dashboard/:id/plugin (POST) - plugin not found', async () => { + const res = await createPlugin({ + name: 'plugin-no', + logo: 'https://logo.com', + positions: [PluginPosition.Dashboard], + }); + const error = await getError(() => + installPlugin(baseId, dashboardId, { + name: 'dddd', + pluginId: res.data.id, + }) + ); + await deletePlugin(res.data.id); + expect(error?.status).toBe(404); + }); + + it('/api/dashboard/:id/plugin/:pluginInstallId/rename (PATCH)', async () => { + const installRes = await installPlugin(baseId, dashboardId, { + name: 'plugin1111', + pluginId, + }); + const newName = 'new-plugin'; + const renameRes = await renamePlugin( + baseId, + dashboardId, + installRes.data.pluginInstallId, + newName + ); + expect(renameDashboardVoSchema.safeParse(renameRes.data).success).toBe(true); + expect(renameRes.data.name).toBe(newName); + }); + + it('/api/dashboard/:id/plugin/:pluginInstallId (DELETE)', async () => { + const installRes = await installPlugin(baseId, dashboardId, { + name: 'plugin1111', + pluginId, + }); + await removePlugin(baseId, dashboardId, installRes.data.pluginInstallId); + const dashboard = await getDashboard(baseId, dashboardId); + expect(dashboard?.data?.pluginMap?.[pluginId]).toBeUndefined(); + }); + }); +}); diff --git a/apps/nestjs-backend/test/oauth-server.e2e-spec.ts b/apps/nestjs-backend/test/oauth-server.e2e-spec.ts index 612651d47..ea6f66860 100644 --- a/apps/nestjs-backend/test/oauth-server.e2e-spec.ts +++ b/apps/nestjs-backend/test/oauth-server.e2e-spec.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable sonarjs/no-duplicate-string */ import type { INestApplication } from '@nestjs/common'; -import { DriverClient, HttpError } from '@teable/core'; +import { HttpError } from '@teable/core'; import { GET_TABLE_LIST, generateOAuthSecret, @@ -195,52 +195,49 @@ describe('OpenAPI OAuthController (e2e)', () => { expect(error?.message).toBe('Invalid user'); }); - it.skipIf(globalThis.testConfig.driver === DriverClient.Sqlite)( - '/api/oauth/access_token (POST)', - async () => { - const { transactionID } = await gerAuthorize(axios, oauth); + it('/api/oauth/access_token (POST)', async () => { + const { transactionID } = await gerAuthorize(axios, oauth); - const res = await decision(axios, transactionID!); + const res = await decision(axios, transactionID!); - const url = new URL(res.headers.location); - const code = url.searchParams.get('code'); - const secret = await generateOAuthSecret(oauth.clientId); + const url = new URL(res.headers.location); + const code = url.searchParams.get('code'); + const secret = await generateOAuthSecret(oauth.clientId); - const tokenRes = await anonymousAxios.post( - `/oauth/access_token`, - { - grant_type: 'authorization_code', - code, - client_id: oauth.clientId, - client_secret: secret.data.secret, - redirect_uri: oauth.redirectUris[0], - }, - { - maxRedirects: 0, - headers: { - // eslint-disable-next-line @typescript-eslint/naming-convention - 'Content-Type': 'application/x-www-form-urlencoded', - }, - } - ); - expect(tokenRes.status).toBe(201); - expect(tokenRes.data).toEqual({ - token_type: 'Bearer', - scopes: oauth.scopes, - access_token: expect.any(String), - refresh_token: expect.any(String), - expires_in: expect.any(Number), - refresh_expires_in: expect.any(Number), - }); - - const userInfo = await anonymousAxios.get(`/auth/user`, { + const tokenRes = await anonymousAxios.post( + `/oauth/access_token`, + { + grant_type: 'authorization_code', + code, + client_id: oauth.clientId, + client_secret: secret.data.secret, + redirect_uri: oauth.redirectUris[0], + }, + { + maxRedirects: 0, headers: { - Authorization: `${tokenRes.data.token_type} ${tokenRes.data.access_token}`, + // eslint-disable-next-line @typescript-eslint/naming-convention + 'Content-Type': 'application/x-www-form-urlencoded', }, - }); - expect(userInfo.data.email).toEqual(globalThis.testConfig.email); - } - ); + } + ); + expect(tokenRes.status).toBe(201); + expect(tokenRes.data).toEqual({ + token_type: 'Bearer', + scopes: oauth.scopes, + access_token: expect.any(String), + refresh_token: expect.any(String), + expires_in: expect.any(Number), + refresh_expires_in: expect.any(Number), + }); + + const userInfo = await anonymousAxios.get(`/auth/user`, { + headers: { + Authorization: `${tokenRes.data.token_type} ${tokenRes.data.access_token}`, + }, + }); + expect(userInfo.data.email).toEqual(globalThis.testConfig.email); + }); it('/api/oauth/access_token (POST) - has decision', async () => { const { transactionID } = await gerAuthorize(axios, oauth); diff --git a/apps/nestjs-backend/test/plugin.e2e-spec.ts b/apps/nestjs-backend/test/plugin.e2e-spec.ts new file mode 100644 index 000000000..b210afb0a --- /dev/null +++ b/apps/nestjs-backend/test/plugin.e2e-spec.ts @@ -0,0 +1,146 @@ +import type { INestApplication } from '@nestjs/common'; +import { + createPlugin, + createPluginVoSchema, + deletePlugin, + getPlugin, + getPluginCenterList, + getPluginCenterListVoSchema, + getPlugins, + getPluginsVoSchema, + getPluginVoSchema, + PluginPosition, + PluginStatus, + publishPlugin, + submitPlugin, + updatePlugin, +} from '@teable/openapi'; +import { getError } from './utils/get-error'; +import { initApp } from './utils/init-app'; + +const mockPlugin = { + name: 'plugin', + logo: '/plugin/xxxxxxx', + description: 'desc', + detailDesc: 'detail', + helpUrl: 'https://help.com', + positions: [PluginPosition.Dashboard], + i18n: { + en: { + name: 'plugin', + description: 'desc', + detailDesc: 'detail', + }, + }, +}; +describe('PluginController', () => { + let app: INestApplication; + let pluginId: string; + beforeAll(async () => { + const appCtx = await initApp(); + app = appCtx.app; + }); + + beforeEach(async () => { + const res = await createPlugin(mockPlugin); + pluginId = res.data.id; + }); + + afterEach(async () => { + await deletePlugin(pluginId); + }); + + afterAll(async () => { + await app.close(); + }); + + it('/api/plugin (POST)', async () => { + const res = await createPlugin(mockPlugin); + expect(createPluginVoSchema.strict().safeParse(res.data).success).toBe(true); + expect(res.data.status).toBe(PluginStatus.Developing); + expect(res.data.pluginUser).not.toBeUndefined(); + await deletePlugin(res.data.id); + }); + + it('/api/plugin/{pluginId} (GET)', async () => { + const getRes = await getPlugin(pluginId); + expect(getPluginVoSchema.strict().safeParse(getRes.data).success).toBe(true); + expect(getRes.data.status).toBe(PluginStatus.Developing); + expect(getRes.data.pluginUser).not.toBeUndefined(); + expect(getRes.data.pluginUser?.name).toEqual('plugin'); + }); + + it('/api/plugin/{pluginId} (GET) - 404', async () => { + const error = await getError(() => getPlugin('invalid-id')); + expect(error?.status).toBe(404); + }); + + it('/api/plugin (GET)', async () => { + const getRes = await getPlugins(); + expect(getPluginsVoSchema.safeParse(getRes.data).success).toBe(true); + expect(getRes.data).toHaveLength(2); + }); + + it('/api/plugin/{pluginId} (DELETE)', async () => { + const res = await createPlugin(mockPlugin); + await deletePlugin(res.data.id); + const error = await getError(() => getPlugin(res.data.id)); + expect(error?.status).toBe(404); + }); + + it('/api/plugin/{pluginId} (PUT)', async () => { + const res = await createPlugin(mockPlugin); + const updatePluginRo = { + name: 'updated', + description: 'updated', + detailDesc: 'updated', + helpUrl: 'https://updated.com', + logo: '/plugin/updated', + positions: [PluginPosition.Dashboard], + i18n: { + en: { + name: 'updated', + description: 'updated', + detailDesc: 'updated', + }, + }, + }; + const putRes = await updatePlugin(res.data.id, updatePluginRo); + await deletePlugin(res.data.id); + expect(putRes.data.name).toBe(updatePluginRo.name); + expect(putRes.data.description).toBe(updatePluginRo.description); + expect(putRes.data.detailDesc).toBe(updatePluginRo.detailDesc); + expect(putRes.data.helpUrl).toBe(updatePluginRo.helpUrl); + expect(putRes.data.logo).toEqual(expect.stringContaining(updatePluginRo.logo)); + expect(putRes.data.i18n).toEqual(updatePluginRo.i18n); + }); + + it('/api/plugin/{pluginId}/submit (POST)', async () => { + const res = await createPlugin(mockPlugin); + const submitRes = await submitPlugin(res.data.id); + await deletePlugin(res.data.id); + expect(submitRes.status).toBe(200); + }); + + it('/api/admin/plugin/{pluginId}/publish (PATCH)', async () => { + const res = await createPlugin(mockPlugin); + await submitPlugin(res.data.id); + await publishPlugin(res.data.id); + const getRes = await getPlugin(res.data.id); + await deletePlugin(res.data.id); + expect(getRes.data.status).toBe(PluginStatus.Published); + }); + + it('/api/admin/plugin/center/list (GET)', async () => { + const res = await createPlugin(mockPlugin); + await submitPlugin(res.data.id); + await publishPlugin(res.data.id); + const getRes = await getPluginCenterList(); + await deletePlugin(res.data.id); + + expect(getRes.data).toHaveLength(2); + const plugin = getRes.data.find((p) => p.id === res.data.id); + expect(plugin).not.toBeUndefined(); + expect(getPluginCenterListVoSchema.safeParse(getRes.data).success).toBe(true); + }); +}); diff --git a/apps/nextjs-app/package.json b/apps/nextjs-app/package.json index 1875d2bde..bb3bb1e02 100644 --- a/apps/nextjs-app/package.json +++ b/apps/nextjs-app/package.json @@ -132,6 +132,7 @@ "express": "4.19.1", "filesize": "10.1.1", "fuse.js": "7.0.0", + "github-markdown-css": "5.6.1", "i18next": "23.10.1", "is-port-reachable": "3.1.0", "jschardet": "3.1.3", @@ -145,6 +146,7 @@ "next-seo": "6.5.0", "next-transpile-modules": "10.0.1", "nprogress": "0.2.0", + "penpal": "6.2.2", "picocolors": "1.0.0", "qrcode.react": "3.1.0", "react": "18.2.0", @@ -168,6 +170,7 @@ "recharts": "2.12.3", "reconnecting-websocket": "4.4.0", "reflect-metadata": "0.2.1", + "rehype-raw": "7.0.0", "remark-gfm": "4.0.0", "sharedb": "4.1.2", "tailwind-scrollbar": "3.1.0", diff --git a/apps/nextjs-app/src/features/app/blocks/chart/ChartDisplay.tsx b/apps/nextjs-app/src/features/app/blocks/chart/ChartDisplay.tsx deleted file mode 100644 index bfcf889dd..000000000 --- a/apps/nextjs-app/src/features/app/blocks/chart/ChartDisplay.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import { BaseQueryColumnType, type IBaseQueryVo } from '@teable/openapi'; -import { SelectTrigger } from '@teable/ui-lib'; -import { - Label, - Select, - SelectContent, - SelectItem, - SelectValue, - type ChartConfig, -} from '@teable/ui-lib/shadcn'; -import { useMemo, useState } from 'react'; -import { ChartBar } from './chart-show/Bar'; - -export const ChartDisplay = (props: { data: IBaseQueryVo }) => { - const { data } = props; - const { rows, columns } = data; - - const [xAxis, setXAxis] = useState(); - const [yAxis, setYAxis] = useState(); - const [group, setGroup] = useState(); - const yColumns = columns.filter( - (column) => - column.type === BaseQueryColumnType.Aggregation || - (column.fieldSource && - column.fieldSource?.cellValueType === 'number' && - !column.fieldSource.isMultipleCellValue) - ); - - const chartConfig = useMemo(() => { - const column = columns.find((column) => column.column === yAxis); - if (!column) return; - if (!group) { - return { - [column.column]: { - label: column.name, - color: 'hsl(var(--chart-1))', - }, - }; - } - if (!xAxis) return; - const chartConfig: ChartConfig = {}; - rows.forEach((row) => { - const groupValue = row[group] as string; - if (!chartConfig[groupValue]) { - chartConfig[groupValue] = { - label: groupValue, - color: `hsl(var(--chart-${Object.keys(chartConfig).length + 1}))`, - }; - } - }); - return chartConfig; - }, [columns, group, rows, yAxis, xAxis]); - - const convertRows = useMemo(() => { - if (!chartConfig || !group || !xAxis || !yAxis) return rows; - const xAxisColumn = columns.find((column) => column.column === xAxis); - if (!xAxisColumn) return rows; - const rowsMap: Record> = {}; - - rows.forEach((row) => { - const groupValue = row[group] as string; - const key = row[xAxis] as string; - const existRow = rowsMap[key]; - if (existRow) { - rowsMap[key] = { - ...existRow, - [groupValue]: row[yAxis], - }; - } else { - rowsMap[key] = { - [xAxis]: row[xAxis], - ...Object.keys(chartConfig).reduce( - (pre, cur) => { - pre[cur] = groupValue === cur ? (row[yAxis] as number) : 0; - return pre; - }, - {} as Record - ), - }; - } - }); - return Object.values(rowsMap); - }, [chartConfig, columns, group, rows, xAxis, yAxis]); - - return ( -
-
-
- - -
-
- - -
-
- - -
-
- {chartConfig && xAxis && ( - - )} -
- ); -}; diff --git a/apps/nextjs-app/src/features/app/blocks/chart/ChartLayout.tsx b/apps/nextjs-app/src/features/app/blocks/chart/ChartLayout.tsx deleted file mode 100644 index 2f19e5330..000000000 --- a/apps/nextjs-app/src/features/app/blocks/chart/ChartLayout.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import type { IGetBaseVo, ITableVo } from '@teable/openapi'; -import { SessionProvider } from '@teable/sdk'; -import type { IUser } from '@teable/sdk'; -import { AnchorContext, AppProvider, BaseProvider, TableProvider } from '@teable/sdk/context'; -import { useRouter } from 'next/router'; -import React from 'react'; -import { useTranslation } from 'react-i18next'; -import { AppLayout } from '@/features/app/layouts'; -import { useSdkLocale } from '../../hooks/useSdkLocale'; -import { BasePermissionListener } from '../base/BasePermissionListener'; - -export const ChartLayout: React.FC<{ - children: React.ReactNode; - tableServerData: ITableVo[]; - baseServerData: IGetBaseVo; - user?: IUser; -}> = ({ children, tableServerData, baseServerData, user }) => { - const router = useRouter(); - const { baseId } = router.query; - const sdkLocale = useSdkLocale(); - const { i18n } = useTranslation(); - - return ( - - - - - - - -
- {children} -
-
-
-
-
-
-
- ); -}; diff --git a/apps/nextjs-app/src/features/app/blocks/chart/ChartPage.tsx b/apps/nextjs-app/src/features/app/blocks/chart/ChartPage.tsx deleted file mode 100644 index 680d72fbe..000000000 --- a/apps/nextjs-app/src/features/app/blocks/chart/ChartPage.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { useMutation } from '@tanstack/react-query'; -import { baseQuery, type IBaseQuery } from '@teable/openapi'; -import type { IBaseQueryBuilderRef } from '@teable/sdk/components'; -import { BaseQueryBuilder } from '@teable/sdk/components'; -import { useBaseId } from '@teable/sdk/hooks'; -import { Button } from '@teable/ui-lib'; -import { useRef, useState } from 'react'; -import { ChartDisplay } from './ChartDisplay'; - -export const ChartPage = () => { - const [query, setQuery] = useState(); - const baseId = useBaseId(); - const queryBuilderRef = useRef(null); - - const { mutate: baseQueryMutate, data } = useMutation({ - mutationFn: ({ baseId, query }: { baseId: string; query: IBaseQuery }) => - baseQuery(baseId, query), - }); - - return ( -
-

Chart Page

-
- -
- - {data?.data && } -
- ); -}; diff --git a/apps/nextjs-app/src/features/app/blocks/chart/chart-show/Bar.tsx b/apps/nextjs-app/src/features/app/blocks/chart/chart-show/Bar.tsx deleted file mode 100644 index aae809ce9..000000000 --- a/apps/nextjs-app/src/features/app/blocks/chart/chart-show/Bar.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { - Card, - CardContent, - ChartContainer, - ChartLegend, - ChartLegendContent, - ChartTooltip, - ChartTooltipContent, -} from '@teable/ui-lib'; -import type { ChartConfig } from '@teable/ui-lib'; -import { useState } from 'react'; -import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from 'recharts'; -import type { Payload } from 'recharts/types/component/DefaultLegendContent'; - -export const ChartBar = (props: { - chartData: Record[]; - chartConfig: ChartConfig; - xAxis: string; -}) => { - const { chartData, chartConfig, xAxis } = props; - - const [hoverLegend, setHoverLegend] = useState(); - const [hiddenLegends, setHiddenLegends] = useState([]); - - const handleLegendMouseEnter = (o: Payload) => { - const { dataKey } = o; - setHoverLegend(dataKey as string); - }; - - const handleLegendMouseLeave = () => { - setHoverLegend(undefined); - }; - - const handleLegendClick = (o: Payload) => { - const { dataKey } = o; - if (hiddenLegends.includes(dataKey as string)) { - setHiddenLegends(hiddenLegends.filter((legend) => legend !== dataKey)); - } else { - setHiddenLegends([...hiddenLegends, dataKey as string]); - } - }; - - return ( - - - - - - - - } - /> - } /> - {Object.keys(chartConfig).map((key) => ( - - ))} - - - - - ); -}; diff --git a/apps/nextjs-app/src/features/app/blocks/setting/oauth-app/manage/OAuthAppDetailLayout.tsx b/apps/nextjs-app/src/features/app/blocks/setting/components/FormPageLayout.tsx similarity index 69% rename from apps/nextjs-app/src/features/app/blocks/setting/oauth-app/manage/OAuthAppDetailLayout.tsx rename to apps/nextjs-app/src/features/app/blocks/setting/components/FormPageLayout.tsx index 2d050af9d..8da76780d 100644 --- a/apps/nextjs-app/src/features/app/blocks/setting/oauth-app/manage/OAuthAppDetailLayout.tsx +++ b/apps/nextjs-app/src/features/app/blocks/setting/components/FormPageLayout.tsx @@ -1,19 +1,18 @@ import { Spin } from '@teable/ui-lib/base'; import { Button, Separator } from '@teable/ui-lib/shadcn'; import { useTranslation } from 'next-i18next'; -import { oauthAppConfig } from '@/features/i18n/oauth-app.config'; -interface IOAuthAppDetailLayoutProps { +interface IFormPageLayoutProps { children: React.ReactNode | React.ReactNode[] | null; loading?: boolean; onSubmit?: () => void; onCancel?: () => void; } -export const OAuthAppDetailLayout = (props: IOAuthAppDetailLayoutProps) => { +export const FormPageLayout = (props: IFormPageLayoutProps) => { const { children, onCancel, onSubmit, loading } = props; - const { t } = useTranslation(oauthAppConfig.i18nNamespaces); + const { t } = useTranslation('common'); return (
@@ -21,11 +20,11 @@ export const OAuthAppDetailLayout = (props: IOAuthAppDetailLayoutProps) => {
diff --git a/apps/nextjs-app/src/features/app/blocks/setting/oauth-app/manage/OAuthAppEdit.tsx b/apps/nextjs-app/src/features/app/blocks/setting/oauth-app/manage/OAuthAppEdit.tsx index 1f3d54226..ff5f78dcf 100644 --- a/apps/nextjs-app/src/features/app/blocks/setting/oauth-app/manage/OAuthAppEdit.tsx +++ b/apps/nextjs-app/src/features/app/blocks/setting/oauth-app/manage/OAuthAppEdit.tsx @@ -10,7 +10,7 @@ import { useTranslation } from 'next-i18next'; import { useRef, useState } from 'react'; import { CopyButton } from '@/features/app/components/CopyButton'; import { oauthAppConfig } from '@/features/i18n/oauth-app.config'; -import { OAuthAppDetailLayout } from './OAuthAppDetailLayout'; +import { FormPageLayout } from '../../components/FormPageLayout'; import type { IOAuthAppFormRef } from './OAuthAppForm'; import { OAuthAppForm } from './OAuthAppForm'; @@ -58,7 +58,7 @@ export const OAuthAppEdit = (props: IOAuthAppEditProps) => { }); return ( - { @@ -154,6 +154,6 @@ export const OAuthAppEdit = (props: IOAuthAppEditProps) => { {!queryLoading && ( )} - + ); }; diff --git a/apps/nextjs-app/src/features/app/blocks/setting/oauth-app/manage/OAuthAppNew.tsx b/apps/nextjs-app/src/features/app/blocks/setting/oauth-app/manage/OAuthAppNew.tsx index cbf0904f9..ab6315550 100644 --- a/apps/nextjs-app/src/features/app/blocks/setting/oauth-app/manage/OAuthAppNew.tsx +++ b/apps/nextjs-app/src/features/app/blocks/setting/oauth-app/manage/OAuthAppNew.tsx @@ -1,7 +1,7 @@ import { useMutation } from '@tanstack/react-query'; import { oauthCreate, type OAuthCreateRo } from '@teable/openapi'; import { useRef, useState } from 'react'; -import { OAuthAppDetailLayout } from './OAuthAppDetailLayout'; +import { FormPageLayout } from '../../components/FormPageLayout'; import type { IOAuthAppFormRef } from './OAuthAppForm'; import { OAuthAppForm } from './OAuthAppForm'; @@ -25,7 +25,7 @@ export const OAuthAppNew = (props: IOAuthAppNewProps) => { }, }); return ( - { formRef.current?.validate() && mutate(form); @@ -33,6 +33,6 @@ export const OAuthAppNew = (props: IOAuthAppNewProps) => { loading={isLoading} > - + ); }; diff --git a/apps/nextjs-app/src/features/app/blocks/setting/plugin/MarkDownEditor.tsx b/apps/nextjs-app/src/features/app/blocks/setting/plugin/MarkDownEditor.tsx new file mode 100644 index 000000000..3f17a3500 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/setting/plugin/MarkDownEditor.tsx @@ -0,0 +1,48 @@ +import { useTheme } from '@teable/next-themes'; +import { Tabs, TabsContent, TabsList, TabsTrigger, Textarea } from '@teable/ui-lib/shadcn'; +import { useTranslation } from 'next-i18next'; +import { useEffect } from 'react'; +import { MarkdownPreview } from '@/features/app/components/MarkdownPreview'; +import { settingPluginConfig } from '@/features/i18n/setting-plugin.config'; + +export const MarkDownEditor = (props: { + defaultStatus?: 'write' | 'preview'; + value?: string; + onChange: (value: string) => void; +}) => { + const { defaultStatus = 'write', value, onChange } = props; + const { resolvedTheme: currentTheme } = useTheme(); + const { t } = useTranslation(settingPluginConfig.i18nNamespaces); + + useEffect(() => { + if (currentTheme === 'dark') { + require('github-markdown-css/github-markdown-dark.css'); + } else { + require('github-markdown-css/github-markdown-light.css'); + } + }, [currentTheme]); + return ( +
+ + + + {t('plugin:markdown.write')} + + + {t('plugin:markdown.preview')} + + + +