AbpExtensibleForm
AbpExtensibleForm is part of a reusable module's extension-based page. For a business page owned by one application, explicit columns, buttons and form state are usually sufficient.
Render the registered create or edit fields
import { defineToken, inject, provideAppInitializer } from '@lsw-abpvue/core';
import {
EntityAction,
EntityProp,
ExtensionsService,
FormProp,
PropType,
ToolbarAction,
} from '@lsw-abpvue/components';
import { Validators } from '@lsw-abpvue/theme-shared';
export interface CatalogBook {
id: string;
name: string;
category?: string | undefined;
price?: number | undefined;
concurrencyStamp?: string | undefined;
extraProperties?: Record<string, unknown> | undefined;
}
export const catalogKey = 'Catalog.BooksComponent';
export const CATALOG_PAGE = defineToken<{
add(): void;
edit(record: CatalogBook): Promise<void>;
remove(record: CatalogBook): Promise<void>;
}>('CATALOG_PAGE');
export const catalogExtensions = provideAppInitializer(() => {
const extensions = inject(ExtensionsService);
extensions.entityProps.get<CatalogBook>(catalogKey).addContributor(props =>
props.addTail(
EntityProp.create<CatalogBook>({
name: 'name',
type: PropType.String,
displayName: 'Name',
sortable: true,
}),
),
);
for (const factory of [extensions.createFormProps, extensions.editFormProps]) {
factory.get<CatalogBook>(catalogKey).addContributor(props =>
props.addTail(
FormProp.create<CatalogBook>({
name: 'name',
type: PropType.String,
displayName: 'Name',
validators: () => [Validators.required(), Validators.maxLength(128)],
}),
),
);
}
extensions.entityActions.get<CatalogBook>(catalogKey).addContributor(props =>
props.addManyTail([
EntityAction.create<CatalogBook>({
text: 'Edit',
action: data => data.getInjected(CATALOG_PAGE).edit(data.record),
}),
EntityAction.create<CatalogBook>({
text: 'Delete',
action: data => data.getInjected(CATALOG_PAGE).remove(data.record),
}),
]),
);
extensions.toolbarActions.get<readonly CatalogBook[]>(catalogKey).addContributor(props =>
props.addTail(
ToolbarAction.create<readonly CatalogBook[]>({
text: 'New book',
icon: 'bi bi-plus',
action: data => data.getInjected(CATALOG_PAGE).add(),
}),
),
);
});<template>
<AbpPage title="Books">
<template #toolbar><AbpPageToolbar :data="items" /></template>
<AbpExtensibleTable :data="items" :list="list" record-key="id" searchable />
<AbpRecordModal
:editor="editor"
:label="{ key: 'Catalog::Books', defaultValue: 'Books' }"
:create-title="{ key: 'Catalog::NewBook', defaultValue: 'New book' }"
/>
</AbpPage>
</template>
<script setup lang="ts">
import {
AbpExtensibleTable,
AbpPage,
AbpPageToolbar,
AbpRecordModal,
useRecordEditor,
type RecordEditor,
} from '@lsw-abpvue/components';
import { inject, RestService, useListService, type PagedResultDto } from '@lsw-abpvue/core';
import { onBeforeUnmount, ref } from 'vue';
import { CATALOG_PAGE, catalogKey, type CatalogBook as Book } from './catalog-extensions';
const rest = inject(RestService);
const request = new AbortController();
const loadingDetail = ref(false);
onBeforeUnmount(() => request.abort());
const list = useListService({ persistKey: catalogKey });
const { items } = list.hookToQuery((query, signal) =>
rest.request<never, PagedResultDto<Book>>(
{ method: 'GET', url: '/api/app/documentation-catalog', params: { ...query } },
{ signal },
),
);
const editor: RecordEditor<Book> = useRecordEditor<Book>({
identifier: catalogKey,
providers: [
{
provide: CATALOG_PAGE,
useValue: {
add: () => editor.show(),
edit: editBook,
remove: (record: Book) => editor.remove(record),
},
},
],
reload: () => list.getWithoutPageReset(),
create: body =>
rest.request(
{ method: 'POST', url: '/api/app/documentation-catalog', body },
{ signal: request.signal },
),
update: (id, body) =>
rest.request(
{
method: 'PUT',
url: `/api/app/documentation-catalog/${id}`,
body: { ...editor.editing.value, ...body },
},
{ signal: request.signal },
),
delete: id =>
rest.request(
{ method: 'DELETE', url: `/api/app/documentation-catalog/${id}` },
{ signal: request.signal },
),
idOf: record => record.id,
stampOf: record => record.concurrencyStamp,
nameOf: record => record.name,
deletionMessage: { key: 'Catalog::DeleteBook', defaultValue: 'Delete {0}?' },
});
async function editBook(record: Book): Promise<void> {
if (loadingDetail.value || editor.busy.value) return;
loadingDetail.value = true;
try {
const detail = await rest.request<never, Book>(
{ method: 'GET', url: `/api/app/documentation-catalog/${record.id}` },
{ signal: request.signal },
);
if (!request.signal.aborted) editor.show(detail);
} catch {
/* Request handlers report failure; keep the incomplete editor closed. */
} finally {
loadingDetail.value = false;
}
}
</script>This complete integration assumes /api/app/documentation-catalog supports GET list, POST create, PUT update and DELETE, and returns records with id/name and optional concurrencyStamp/extraProperties. Copy both files, add catalogExtensions to the existing startup providers, and register a route for CatalogModulePage. Defaults are registered before the page mounts; register host contributors after these defaults. The page supplies CATALOG_PAGE commands and does not clear host contributions. A packaged module can expose contributor options through its route resolver as described in the module tutorial. The application layout supplies notification and confirmation hosts.
Data and lifecycle
The editor builds create fields for an absent record and edit fields for an existing record. The form owns reactive controls; this renderer groups and displays them. A field-{name} slot receives { prop, control }; bind control.value in an override. Custom controls must respect disabled/readonly and emit updates. Hidden fields can still have validators: visibility alone does not remove a control or validation. Extra properties must be saved with toRequestBody() so existing hidden extra values survive. Backend validation remains authoritative.
Extend the module
Use a stable public component identifier such as Catalog.BooksComponent. Consumers register contributors under that identifier. A changed key starts a different extension bucket and preference identity. Contributors execute in registration order; adding the same name again does not automatically replace it.
A reusable module should expose defaults and contributor options through its public entry. Follow the reusable module tutorial for package startup and route registration. Extension behavior explains defaults, ordering and diagnostics.
Check the completed workflow
Open the list, create a valid record, edit a fetched record, reject deletion once and confirm it once. Check a failed request retains values, extra properties survive an edit, and list refresh preserves or resets the page as intended. Then add one host contributor and verify the default still exists exactly once.
Install the supplied catalogue backend before running this page. It uses an in-memory teaching store and the existing AbpIdentity.Users policy; the endpoint is not part of every ABP application.
Props, events and slots
Types come from the public contract and defaults from the current implementation. A dash means no explicit default; optional boolean props are normally false when omitted.
Props
| Name | Type | Required | Default |
|---|---|---|---|
form | ExtensibleForm<R> | Yes | — |
record | R | undefined | No | — |
Events
No component-specific events are declared.
Slots
| Name | Context |
|---|---|
field-${string} | (props: { prop: FormProp<R>; control: AbpFormControl }) => unknown |