-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy path485ec31e.7a8c6c1c.js
1 lines (1 loc) · 19.2 KB
/
485ec31e.7a8c6c1c.js
1
"use strict";(self.webpackChunkadminforth=self.webpackChunkadminforth||[]).push([[381],{6438:(n,e,t)=>{t.r(e),t.d(e,{assets:()=>d,contentTitle:()=>r,default:()=>u,frontMatter:()=>i,metadata:()=>a,toc:()=>l});const a=JSON.parse('{"id":"tutorial/Customization/customPages","title":"Custom pages","description":"Learn how to create custom pages in AdminForth.","source":"@site/docs/tutorial/03-Customization/06-customPages.md","sourceDirName":"tutorial/03-Customization","slug":"/tutorial/Customization/customPages","permalink":"/docs/tutorial/Customization/customPages","draft":false,"unlisted":false,"tags":[],"version":"current","sidebarPosition":6,"frontMatter":{"description":"Learn how to create custom pages in AdminForth.","image":"/ogs/customPages.png"},"sidebar":"tutorialSidebar","previous":{"title":"Limiting actions access","permalink":"/docs/tutorial/Customization/limitingAccess"},"next":{"title":"Alerts and confirmations","permalink":"/docs/tutorial/Customization/alert"}}');var s=t(4848),o=t(8453);const i={description:"Learn how to create custom pages in AdminForth.",image:"/ogs/customPages.png"},r="Custom pages",d={},l=[{value:"Defining custom API for own page and components",id:"defining-custom-api-for-own-page-and-components",level:2},{value:"Custom pages without menu item",id:"custom-pages-without-menu-item",level:2},{value:"Passing meta attributes to the page",id:"passing-meta-attributes-to-the-page",level:3}];function c(n){const e={a:"a",blockquote:"blockquote",code:"code",em:"em",h1:"h1",h2:"h2",h3:"h3",header:"header",img:"img",p:"p",pre:"pre",...(0,o.R)(),...n.components};return(0,s.jsxs)(s.Fragment,{children:[(0,s.jsx)(e.header,{children:(0,s.jsx)(e.h1,{id:"custom-pages",children:"Custom pages"})}),"\n",(0,s.jsx)(e.p,{children:"Most Admin Panels should have some Dashboards or custom pages."}),"\n",(0,s.jsx)(e.p,{children:"In AdminForth creation of custom page is very simple."}),"\n",(0,s.jsxs)(e.p,{children:["Create a Vue component in the ",(0,s.jsx)(e.code,{children:"custom"})," directory of your project, e.g. ",(0,s.jsx)(e.code,{children:"Dashboard.vue"}),":"]}),"\n",(0,s.jsx)(e.pre,{children:(0,s.jsx)(e.code,{className:"language-html",metastring:'title="./custom/Dashboard.vue"',children:'<template>\n <div class="px-4 py-4 bg-blue-50 dark:bg-gray-900 dark:shadow-none min-h-screen">\n \n <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">\n <div class="max-w-md w-full bg-white rounded-lg shadow dark:bg-gray-800 p-4 md:p-5" v-if="data">\n <div>\n <h5 class="leading-none text-3xl font-bold text-gray-900 dark:text-white pb-2">{{ data.totalAparts }}</h5>\n <p class="text-base font-normal text-gray-500 dark:text-gray-400">{{ $t(\'Apartment last 7 days | Apartments last 7 days\', data.totalAparts) }}</p>\n </div>\n <BarChart\n :data="apartsCountsByDaysChart"\n :series="[{\n name: $t(\'Added apartments\'),\n fieldName: \'count\',\n color: COLORS[0],\n }]"\n :options="{\n chart: {\n height: 130,\n },\n yaxis: {\n stepSize: 1,\n labels: { show: false },\n },\n grid: {\n show: false,\n }\n }"\n />\n </div>\n\n <div class="max-w-md w-full bg-white rounded-lg shadow dark:bg-gray-800 p-4 md:p-5" v-if="data">\n <p class="text-base font-normal text-gray-500 dark:text-gray-400">{{ $t(\'Top countries\') }}</p>\n <PieChart\n :data="topCountries"\n :options="{\n chart: { type: \'pie\'},\n legend: {\n show: false,\n },\n dataLabels: {\n enabled: true,\n formatter: function (value, o) {\n const countryISO = o.w.config.labels[o.seriesIndex];\n return countryISO;\n }\n },\n }"\n />\n </div>\n\n <div class="w-full bg-white rounded-lg shadow dark:bg-gray-800 p-4 md:p-5 lg:row-span-2 xl:col-span-2" v-if="data">\n <div class="grid grid-cols-2 py-3">\n <dl>\n <dt class="text-base font-normal text-gray-500 dark:text-gray-400 pb-1">{{ $t(\'Listed price\') }}</dt>\n <dd class="leading-none text-xl font-bold dark:text-green-400" :style="{color:COLORS[0]}">{{\n new Intl.NumberFormat(\'en-US\', { style: \'currency\', currency: \'USD\', maximumFractionDigits: 0, }).format(\n data.totalListedPrice,\n ) }}\n </dd>\n </dl>\n <dl>\n <dt class="text-base font-normal text-gray-500 dark:text-gray-400 pb-1">{{ $t(\'Unlisted price\') }}</dt>\n <dd class="leading-none text-xl font-bold dark:text-red-500" :style="{color:COLORS[1]}">{{\n new Intl.NumberFormat(\'en-US\', { style: \'currency\', currency: \'USD\', maximumFractionDigits: 0, }).format(\n data.totalUnlistedPrice,\n ) }}\n </dd>\n </dl>\n </div>\n\n <BarChart\n :data="listedVsUnlistedCountByDays"\n :series="[{\n name: $t(\'Listed Count\'),\n fieldName: \'listed\',\n color: COLORS[0],\n },\n {\n name: $t(\'Unlisted Count\'),\n fieldName: \'unlisted\',\n color: COLORS[1],\n }]"\n :options="{\n chart: {\n height: 500,\n },\n xaxis: {\n labels: { show: true },\n stepSize: 1, // since count is integer, otherwise axis will be float\n },\n yaxis: {\n labels: { show: true }\n },\n grid: {\n show: true,\n },\n plotOptions: {\n bar: { \n horizontal: true, // by default bars are vertical\n }\n },\n }"\n />\n\n </div>\n\n <div class="max-w-md w-full bg-white rounded-lg shadow dark:bg-gray-800 p-4 md:p-5" v-if="data">\n <p class="text-base font-normal text-gray-500 dark:text-gray-400">{{ $t(\'Apartment by rooms\') }}</p>\n <PieChart\n :data="apartsCountsByRooms"\n :options="{\n chart: { type: \'donut\'},\n plotOptions: {\n pie: {\n donut: {\n labels: {\n total: {\n show: true,\n label: $t(\'Total square\'),\n formatter: () => `${data.totalSquareMeters.toFixed(0)} m\xb2`,\n },\n },\n },\n },\n },\n }"\n />\n </div>\n\n <div class="max-w-md w-full bg-white rounded-lg shadow dark:bg-gray-800 p-4 md:p-5" v-if="data">\n <p class="text-base font-normal text-gray-500 dark:text-gray-400">{{ $t(\'Unlisted vs Listed price\' ) }}</p>\n\n <AreaChart \n :data="listedVsUnlistedPriceByDays"\n :series="[{\n name: $t(\'Listed\'),\n fieldName: \'listedPrice\',\n color: COLORS[0],\n },\n {\n name: $t(\'Unlisted\'),\n fieldName: \'unlistedPrice\',\n color: COLORS[1],\n }]"\n :options="{\n chart: {\n height: 250,\n },\n yaxis: {\n labels: {\n formatter: function (value) {\n return \'$\' + value;\n }\n }\n },\n }"\n />\n </div>\n\n </div>\n </div>\n</template>\n\n<script setup lang="ts">\nimport { ref, type Ref, onMounted, computed } from \'vue\';\nimport dayjs from \'dayjs\';\nimport { callApi } from \'@/utils\';\nimport { useI18n } from \'vue-i18n\';\nimport adminforth from \'@/adminforth\';\nimport { AreaChart, BarChart, PieChart } from \'@/afcl\';\n\nconst data: Ref<{listedVsUnlistedPriceByDays: any, listedVsUnlistedByDays: any, \n apartsByDays: any, apartsCountsByRooms: any, topCountries: any, totalAparts: any} | null> = ref(null);\n\nconst { t } = useI18n();\n\nconst COLORS = ["#4E79A7", "#F28E2B", "#E15759", "#76B7B2", "#59A14F"]\n\nconst apartsCountsByDaysChart = computed(() => {\n return data.value.apartsByDays?.reverse().map(\n (item) => ({\n x: dayjs(item.day).format(\'DD MMM\'),\n count: item.count\n })\n );\n});\n\nconst listedVsUnlistedPriceByDays = computed(() => {\n return data.value.listedVsUnlistedPriceByDays?.map(\n (item) => ({\n x: dayjs(item.day).format(\'DD MMM\'),\n listedPrice: item.listedPrice.toFixed(2),\n unlistedPrice: item.unlistedPrice.toFixed(2),\n })\n );\n});\n\nconst listedVsUnlistedCountByDays = computed(() => {\n return data.value.listedVsUnlistedByDays?.map(\n (item) => ({\n x: dayjs(item.day).format(\'DD MMM\'),\n listed: item.listed,\n unlisted: item.unlisted,\n })\n );\n});\n\nconst apartsCountsByRooms = computed(() => {\n return data.value.apartsCountsByRooms?.map(\n (item, i) => ({\n label: t(`{number_of_rooms} rooms`, { number_of_rooms: item.number_of_rooms }),\n amount: item.count,\n color: COLORS[i],\n })\n );\n});\n\nconst topCountries = computed(() => {\n return data.value.topCountries?.map(\n (item, i) => ({\n label: item.country,\n amount: item.count,\n color: COLORS[i],\n })\n );\n});\n\nonMounted(async () => {\n // Fetch data from the API\n try {\n data.value = await callApi({path: \'/api/dashboard/\', method: \'GET\'});\n } catch (error) {\n adminforth.alert({\n message: t(`Error fetching data: {message}`, { message: error.message }),\n variant: \'danger\',\n });\n }\n})\n<\/script>\n'})}),"\n",(0,s.jsxs)(e.blockquote,{children:["\n",(0,s.jsxs)(e.p,{children:["\u261d\ufe0f use ",(0,s.jsx)(e.a,{href:"https://flowbite.com/",children:"https://flowbite.com/"})," to get pre-designed tailwind design blocks for your pages"]}),"\n"]}),"\n",(0,s.jsx)(e.p,{children:"Now let's add this page to the AdminForth menu and make it homepage instead of Apartments page:"}),"\n",(0,s.jsx)(e.pre,{children:(0,s.jsx)(e.code,{className:"language-ts",metastring:'title="/index.ts"',children:"menu: [\n//diff-add\n {\n//diff-add\n label: 'Dashboard',\n//diff-add\n path: '/overview',\n//diff-add\n homepage: true,\n//diff-add\n icon: 'flowbite:chart-pie-solid',\n//diff-add\n component: '@@/Dashboard.vue',\n//diff-add\n },\n {\n label: 'Core',\n icon: 'flowbite:brain-solid',\n open: true,\n children: [\n {\n//diff-remove\n homepage: true, \n label: 'Apartments',\n icon: 'flowbite:home-solid',\n resourceId: 'aparts',\n },\n ]\n },\n"})}),"\n",(0,s.jsxs)(e.blockquote,{children:["\n",(0,s.jsxs)(e.p,{children:["\u261d\ufe0f To find icon go to ",(0,s.jsx)(e.a,{href:"https://icon-sets.iconify.design/flowbite/?query=chart",children:"https://icon-sets.iconify.design/flowbite/?query=chart"}),", click on icon you like and copy name:\n",(0,s.jsx)(e.img,{alt:"Iconify icon select",src:t(9586).A+"",width:"2215",height:"1532"})]}),"\n"]}),"\n",(0,s.jsx)(e.p,{children:"You might notice that in mounted hook page fetches custom endpoint '/api/dashboard-stats'.\nNow we have to define this endpoint in the backend to make our page work:"}),"\n",(0,s.jsx)(e.h2,{id:"defining-custom-api-for-own-page-and-components",children:"Defining custom API for own page and components"}),"\n",(0,s.jsxs)(e.p,{children:["Open ",(0,s.jsx)(e.code,{children:"index.ts"})," file and add the following code ",(0,s.jsx)(e.em,{children:"BEFORE"})," ",(0,s.jsx)(e.code,{children:"admin.express.serve("})," !"]}),"\n",(0,s.jsx)(e.pre,{children:(0,s.jsx)(e.code,{className:"language-ts",metastring:'title="/index.ts"',children:"\n....\n\napp.get(`${ADMIN_BASE_URL}/api/dashboard/`,\n admin.express.authorize(\n async (req, res) => {\n const days = req.body.days || 7;\n const apartsByDays = admin.resource('aparts').dataConnector.client.prepare(\n `SELECT \n strftime('%Y-%m-%d', created_at) as day, \n COUNT(*) as count \n FROM apartments \n GROUP BY day \n ORDER BY day DESC\n LIMIT ?;\n `\n ).all(days);\n\n const totalAparts = apartsByDays.reduce((acc: number, { count }: { count:number }) => acc + count, 0);\n\n // add listed, unlisted, listedPrice, unlistedPrice\n const listedVsUnlistedByDays = admin.resource('aparts').dataConnector.client.prepare(\n `SELECT \n strftime('%Y-%m-%d', created_at) as day, \n SUM(listed) as listed, \n COUNT(*) - SUM(listed) as unlisted,\n SUM(listed * price) as listedPrice,\n SUM((1 - listed) * price) as unlistedPrice\n FROM apartments\n GROUP BY day\n ORDER BY day DESC\n LIMIT ?;\n `\n ).all(days);\n\n const apartsCountsByRooms = await admin.resource('aparts').dataConnector.client.prepare(\n `SELECT \n number_of_rooms, \n COUNT(*) as count \n FROM apartments \n GROUP BY number_of_rooms \n ORDER BY number_of_rooms;\n `\n ).all();\n\n const topCountries = await admin.resource('aparts').dataConnector.client.prepare(\n `SELECT \n country, \n COUNT(*) as count \n FROM apartments \n GROUP BY country \n ORDER BY count DESC\n LIMIT 4;\n `\n ).all();\n\n const totalSquare = admin.resource('aparts').dataConnector.client.prepare(\n `SELECT \n SUM(square_meter) as totalSquare \n FROM apartments;\n `\n ).get();\n\n const listedVsUnlistedPriceByDays = admin.resource('aparts').dataConnector.client.prepare(\n `SELECT \n strftime('%Y-%m-%d', created_at) as day, \n SUM(listed * price) as listedPrice,\n SUM((1 - listed) * price) as unlistedPrice\n FROM apartments\n GROUP BY day\n ORDER BY day DESC\n LIMIT ?;\n `\n ).all(days);\n \n const totalListedPrice = Math.round(listedVsUnlistedByDays.reduce((\n acc: number, { listedPrice }: { listedPrice:number }\n ) => acc + listedPrice, 0));\n const totalUnlistedPrice = Math.round(listedVsUnlistedByDays.reduce((\n acc: number, { unlistedPrice }: { unlistedPrice:number } \n ) => acc + unlistedPrice, 0));\n\n res.json({ \n apartsByDays,\n totalAparts,\n listedVsUnlistedByDays,\n apartsCountsByRooms,\n topCountries,\n totalSquareMeters: totalSquare.totalSquare,\n totalListedPrice,\n totalUnlistedPrice,\n listedVsUnlistedPriceByDays,\n });\n }\n )\n);\n\n// serve after you added all api\nadmin.express.serve(app, express)\nadmin.discoverDatabases();\n\n"})}),"\n",(0,s.jsxs)(e.blockquote,{children:["\n",(0,s.jsxs)(e.p,{children:["\u261d\ufe0f Please note that we are using ",(0,s.jsx)(e.code,{children:"admin.express.authorize"})," middleware to check if the user is logged in. If you want to make this endpoint public, you can remove this middleware. If user is not logged in, the request will return 401 Unauthorized status code, and protect our statistics from leak."]}),"\n"]}),"\n",(0,s.jsxs)(e.blockquote,{children:["\n",(0,s.jsxs)(e.p,{children:["\u261d\ufe0f Moreover if you wrap your endpoint with ",(0,s.jsx)(e.code,{children:"admin.express.authorize"})," middleware, you can access ",(0,s.jsx)(e.code,{children:"req.adminUser"})," object in your endpoint to get the current user information."]}),"\n"]}),"\n",(0,s.jsxs)(e.blockquote,{children:["\n",(0,s.jsx)(e.p,{children:"\u261d\ufe0f AdminForth does not provide any facility to access data in database. You are free to use any ORM like Prisma, TypeORM, Sequelize,\nmongoose, or just use raw SQL queries against your tables."}),"\n"]}),"\n",(0,s.jsx)(e.p,{children:"Demo:"}),"\n",(0,s.jsx)(e.p,{children:(0,s.jsx)(e.img,{alt:"alt text",src:t(6882).A+"",width:"1400",height:"1050"})}),"\n",(0,s.jsx)(e.h2,{id:"custom-pages-without-menu-item",children:"Custom pages without menu item"}),"\n",(0,s.jsx)(e.p,{children:"Sometimes you might need to add custom page but don't want to add it to the menu."}),"\n",(0,s.jsxs)(e.p,{children:["In this case you can add custom page using ",(0,s.jsx)(e.code,{children:"customization.customPages"})," option:"]}),"\n",(0,s.jsx)(e.pre,{children:(0,s.jsx)(e.code,{className:"language-ts",metastring:'title="/index.ts"',children:"new AdminForth({\n // ...\n customization: {\n customPages: [\n {\n path: '/setup2fa', // route path\n component: { \n file: '@@/pages/TwoFactorsSetup.vue',\n meta: { \n title: 'Setup 2FA', // meta title for this page\n customLayout: true // don't include default layout like menu/header\n }\n }\n }\n ]\n }\n})\n"})}),"\n",(0,s.jsxs)(e.p,{children:["This will register custom page with path ",(0,s.jsx)(e.code,{children:"/setup2fa"})," and will not include it in the menu."]}),"\n",(0,s.jsx)(e.p,{children:"You can navigate user to this page using any router link, e.g.:"}),"\n",(0,s.jsx)(e.pre,{children:(0,s.jsx)(e.code,{className:"language-html",children:'<template>\n <Link to="/setup2fa">Setup 2FA</Link>\n</template>\n'})}),"\n",(0,s.jsx)(e.pre,{children:(0,s.jsx)(e.code,{className:"language-ts",children:"import { Link } from '@/afcl';\n"})}),"\n",(0,s.jsx)(e.h3,{id:"passing-meta-attributes-to-the-page",children:"Passing meta attributes to the page"}),"\n",(0,s.jsxs)(e.p,{children:["You can add custom meta attributes to the page by passing ",(0,s.jsx)(e.code,{children:"meta"})," object to the page:"]}),"\n",(0,s.jsx)(e.pre,{children:(0,s.jsx)(e.code,{className:"language-ts",metastring:'title="/index.ts"',children:"\n customPages: [\n {\n path: '/setup2fa', // route path\n component: { \n file: '@@/pages/TwoFactorsSetup.vue',\n meta: { \n title: 'Setup 2FA', // meta title for this page\n customLayout: true, // don't include default layout like menu/header\n //diff-add\n myAttribute: 'a1'\n }\n }\n }\n ]\n"})}),"\n",(0,s.jsxs)(e.p,{children:["To access passed meta attributes in your page, you can use ",(0,s.jsx)(e.code,{children:"useRoute"})," hook:"]}),"\n",(0,s.jsx)(e.pre,{children:(0,s.jsx)(e.code,{className:"language-ts",children:"import { useRoute } from 'vue-router';\n\nconst route = useRoute();\n\nconsole.log(route.meta.myAttribute); // a1\n"})})]})}function u(n={}){const{wrapper:e}={...(0,o.R)(),...n.components};return e?(0,s.jsx)(e,{...n,children:(0,s.jsx)(c,{...n})}):c(n)}},6882:(n,e,t)=>{t.d(e,{A:()=>a});const a=t.p+"assets/images/dashDemo-363e3ec39b8c08a2f802e1bad9d353ff.gif"},9586:(n,e,t)=>{t.d(e,{A:()=>a});const a=t.p+"assets/images/image-icon-select-ca7b83c5452ac54e6ddb41bac6c0c64c.png"},8453:(n,e,t)=>{t.d(e,{R:()=>i,x:()=>r});var a=t(6540);const s={},o=a.createContext(s);function i(n){const e=a.useContext(o);return a.useMemo((function(){return"function"==typeof n?n(e):{...e,...n}}),[e,n])}function r(n){let e;return e=n.disableParentContext?"function"==typeof n.components?n.components(s):n.components||s:i(n.components),a.createElement(o.Provider,{value:e},n.children)}}}]);