Creating a CRUD App with Patterns

DuoyunUI'spattern elements and helper modules let you quickly create a CRUD app (example, React example). This article will use:

  • <dy-pat-console> to create the app basic layout
  • <dy-pat-table> to create the table page
  • helper/store to create a paginated data manager
  • helper/error to display error messages

Step 1: Create the App Framework

The <dy-pat-console> element uses a two‑column layout and fills the entire viewport. Insert <dy-pat-console> into the body element to see the basic layout.

import { render, html } from '@mantou/gem'; import 'duoyun-ui/patterns/console'; render(html`<dy-pat-console></dy-pat-console>`, document.body);import { createRoot } from 'react-dom/client'; import DyPatConsole from 'duoyun-ui/react/DyPatConsole'; createRoot(document.body).render(<DyPatConsole />);

Define Routes and Sidebar Navigation

<dy-pat-console> uses <gem-route> to implement routing. Routes are not only used to match and display content, they can also be used as navigation parameters; the sidebar navigation of <dy-pat-console> also supports the route format, so define them together before rendering:

import { html } from '@mantou/gem'; import type { Routes, NavItems } from 'duoyun-ui/patterns/console'; const routes = { home: { pattern: '/', title: 'Home', getContent() { return html`Home`; }, }, item: { pattern: '/items/:id', title: 'Item Page', async getContent(params) { return html`<console-page-item>${JSON.stringify(params)}</console-page-item>`; }, }, } satisfies Routes; const navItems: NavItems = [ routes.home, { ...routes.item, params: { id: crypto.randomUUID() }, }, ];import { createRoot } from 'react-dom/client'; import type { Routes, NavItems } from 'duoyun-ui/patterns/console'; const routes = { home: { pattern: '/', title: 'Home', getContent(_, ele) { ele.react?.unmount(); ele.react = createRoot(ele); ele.react.render(<>Home</>); }, }, item: { pattern: '/items/:id', title: 'Item Page', async getContent(params, ele) { ele.react?.unmount(); ele.react = createRoot(ele); ele.react.render(<>{JSON.stringify(params)}</>); }, }, } satisfies Routes; const navItems: NavItems = [ routes.home, { ...routes.item, params: { id: crypto.randomUUID() }, }, ];

WARNING

When rendering pages with React, to better compatibility with Gem, you need to unmount the mounted root node first, then recreate a React root and render.

Define User Info and Global Menu

After that, you can specify user info to identify the user, and also define some global commands, such as switching language or logging out:

import { Toast } from 'duoyun-ui/elements/toast'; import type { ContextMenus, UserInfo } from 'duoyun-ui/patterns/console'; const contextMenus: ContextMenus = [ { text: 'Languages', handle: () => Toast.open('info', 'No Implement!'), }, { text: '---' }, { text: 'Logout', handle: () => Toast.open('error', 'No Implement!'), danger: true, }, ]; const userInfo: UserInfo = { username: 'Mantou', org: 'DuoyunUI', profile: '/about', };

Configure <dy-pat-console>

Modify the render function and specify other configuration options:

render( html` <dy-pat-console name="DuoyunUI" .logo=${'https://duoyun-ui.gemjs.org/logo.png'} .routes=${routes} .navItems=${navItems} .contextMenus=${contextMenus} .userInfo=${userInfo} .keyboardAccess=${true} .responsive=${true} ></dy-pat-console> `, document.body, );createRoot(document.body).render( <DyPatConsole name="DuoyunUI" logo="https://duoyun-ui.gemjs.org/logo.png" routes={routes} navItems={navItems} contextMenus={contextMenus} userInfo={userInfo} keyboardAccess={true} responsive={true} />, );

Step 2: Create the Page

Now implement a table page with CRUD capabilities. First create a page that shows an empty table (item.ts), then modify the route:

import { html, GemElement, connectStore, customElement } from '@mantou/gem'; import 'duoyun-ui/patterns/table'; @customElement('console-page-item') export class ConsolePageItemElement extends GemElement { render = () => { return html`<dy-pat-table></dy-pat-table>`; }; }import { useState, useEffect } from 'react'; import { connect } from '@mantou/gem'; import DyPatTable from 'duoyun-ui/react/DyPatTable'; export function Item() { return <DyPatTable></DyPatTable>; } { item: { pattern: '/items/:id', title: 'Item Page', async getContent() { + await import('./item'); return html`<console-page-item></console-page-item>`; }, } }{ item: { pattern: '/items/:id', title: 'Item Page', async getContent(params, ele) { - createRoot(ele).render(<>{JSON.stringify(params)}</>); + const { Item } = await import('./item'); + createRoot(ele).render(<Item />); }, }, }

Fetch the List and Render the Table

Next, fetch data from the backend and fill the table. URL parameters such as id can be read from locationStore, which is created by <dy-pat-console> to respond to app route updates, and it will not update from a page that is loading but not yet displayed. You need to bind it to the page so that the page reacts when id changes.

import { html, GemElement, connectStore, customElement, createState, effect } from '@mantou/gem'; import { get } from '@mantou/gem/helper/request'; import { locationStore } from 'duoyun-ui/patterns/console'; import type { FilterableColumn } from 'duoyun-ui/patterns/table'; import 'duoyun-ui/patterns/table'; @customElement('console-page-item') @connectStore(locationStore) export class ConsolePageItemElement extends GemElement { #state = createState<{ data: any }>({}); #columns: FilterableColumn<any>[] = [ { title: 'No', dataIndex: 'id', }, ]; @effect((i) => [locationStore.params.id]) #fetch = async ([id]) => { const data = await get(`https://jsonplaceholder.typicode.com/users`); this.#state({ data }); }; render = () => { return html` <dy-pat-table filterable .columns=${this.#columns} .data=${this.#state.data}></dy-pat-table> `; }; }import { useState, useEffect } from 'react'; import { connect } from '@mantou/gem'; import { get } from '@mantou/gem/helper/request'; import { locationStore } from 'duoyun-ui/patterns/console'; import DyPatTable, { FilterableColumn } from 'duoyun-ui/react/DyPatTable'; export function Item() { const [_, update] = useState({}); useEffect(() => connect(locationStore, () => update({})), []); const [data, updateData] = useState(); useEffect(() => { // const id = locationStore.params.id; get(`https://jsonplaceholder.typicode.com/users`).then(updateData); }, [locationStore.params.id]); const columns: FilterableColumn<any>[] = [ { title: 'No', dataIndex: 'id', }, ]; return <DyPatTable filterable={true} columns={columns} data={data}></DyPatTable>; }

Add Delete and Update Actions to Table Rows

Just add a column with getActions:

import { ContextMenu } from 'duoyun-ui/elements/contextmenu'; const columns: FilterableColumn<any>[] = [ // ... { title: '', getActions: (r, activeElement) => [ { text: 'Edit', handle: () => { onUpdate(r); }, }, { text: 'Delete', danger: true, handle: async () => { await ContextMenu.confirm(`Confirm delete ${r.username}?`, { activeElement, danger: true, }); console.log('Delete: ', r); }, }, ], }, ];

Implement Update and Delete

First define a form just like you would define a table:

import type { FormItem } from 'duoyun-ui/patterns/form'; const formItems: FormItem<any>[] = [ { type: 'text', field: 'username', label: 'Username', required: true, }, ];

Then implement onCreate and onUpdate, and add a Create button to the page:

import { createForm } from 'duoyun-ui/patterns/form'; function onUpdate(r) { createForm({ data: r, header: `Update: ${r.id}`, formItems: formItems, prepareOk: async (data) => { console.log(data); }, }).catch((data) => { console.log(data); }); } function onCreate() { createForm({ type: 'modal', data: {}, header: `Create`, formItems: formItems, prepareOk: async (data) => { console.log(data); }, }).catch((data) => { console.log(data); }); } // ... html` <dy-pat-table filterable .columns=${this.#columns} .data=${this.state.data}> <dy-button @click=${onCreate}>Create</dy-button> </dy-pat-table> `;// ... <DyPatTable filterable={true} columns={columns} data={data}> <DyButton onClick={onCreate}>Create</DyButton> </DyPatTable>

Step 3: Server‑Side Pagination (Optional)

So far the app has pagination, search, and filter features, but they are all client‑side, which means you need to provide all data to <dy-pat-table> at once. In a real production environment, the server usually handles pagination, search, and filter; you only need a small change to achieve this:

// ... html` <dy-pat-table filterable .columns=${this.#columns} .paginationStore=${pagination.store} @fetch=${this.#onFetch} > <dy-button @click=${onCreate}>Create</dy-button> </dy-pat-table> `;// ... <DyPatTable filterable={true} columns={columns} paginationStore={pagination.store} onfetch={onFetch} > <DyButton onClick={onCreate}>Create</DyButton> </DyPatTable>

Here, store is created with createPaginationStore:

import { Time } from 'duoyun-ui/lib/time'; import { createPaginationStore } from 'duoyun-ui/helper/store'; import type { FetchEventDetail } from 'duoyun-ui/patterns/table'; const pagination = createPaginationStore({ storageKey: 'users', cacheItems: true, pageContainItem: true, }); // Mock real API const fetchList = (args: FetchEventDetail) => { return get(`https://jsonplaceholder.typicode.com/users`).then((list) => { list.forEach((e, i) => { e.updated = new Time().subtract(i + 1, 'd').getTime(); e.id += 10 * (args.page - 1); }); return { list, count: list.length * 3 }; }); }; const onFetch = ({ detail }: CustomEvent<FetchEventDetail>) => { pagination.updatePage(fetchList, detail); };
Optimize Search Result Display (Optional)

When switching between having a search term and not having one, the page cannot immediately switch to the new list; you can assign a separate pagination for the search term:

@customElement('console-page-item') export class ConsolePageItemElement extends GemElement { #state = createState({ pagination: pagination, paginationMap: new Map([['', pagination]]), }); #onFetch = ({ detail }: CustomEvent<FetchEventDetail>) => { let pagination = this.#state.paginationMap.get(detail.searchAndFilterKey); if (!pagination) { pagination = createPaginationStore<Item>({ cacheItems: true, pageContainItem: true, }); this.#state.paginationMap.set(detail.searchAndFilterKey, pagination); } this.#state({ pagination }); pagination.updatePage(fetchList, detail); }; }

TIP

<dy-pat-table> also supports:

  • Using expandedRowRender to expand rows, and @expand to get the expand event
  • Using selectable to make the table selectable, and getSelectedActions to add commands for selected items

Step 4: Handle Errors

Import helper/error at the top of the main file; it shows error messages via Toast and can also display unhandled rejected promises:

import 'duoyun-ui/helper/error';

Now the CRUD app should look like this.