Latest Snippets
Dynamic has many relation
Allows e.g. pagination or filtering on HasMany relation.
DynamicHasMany.tsx
import { Component, EntitySubTree, HasMany, SugaredRelativeEntityList, useEntity, useExtendTree } from '@contember/admin'import React, { ReactNode, useEffect, useRef, useState } from 'react'import { useObjectMemo } from '@contember/react-utils'export type DynamicHasManyProps =& SugaredRelativeEntityList& { children: ReactNode }export const DynamicHasMany = Component<DynamicHasManyProps>(({ children, ...props }) => {const listParams = useObjectMemo(props)const [initialListParams] = useState(listParams)const [displayedState, setDisplayedState] = useState<{treeRoot: undefined | stringlist: SugaredRelativeEntityList} | undefined>(undefined)const extendTree = useExtendTree()const entity = useEntity()const requestRef = useRef(1)useEffect(() => {(async () => {if (initialListParams === listParams || !entity.idOnServer) {setDisplayedState(undefined)return}const request = ++requestRef.currentconst entityNode = (<EntitySubTree entity={{ entityName: entity.name, where: { id: entity.idOnServer } }}><HasMany {...listParams}>{children}</HasMany></EntitySubTree>)const treeRoot = await extendTree(entityNode)if (request !== requestRef.current) {return}setDisplayedState({ treeRoot, list: listParams })})()}, [children, entity.idOnServer, entity.name, extendTree, initialListParams, listParams])if (!displayedState) {return (<HasMany {...initialListParams}>{children}</HasMany>)}return (<EntitySubTree entity={{ entityName: entity.name, where: { id: entity.idOnServer! } }} treeRootId={displayedState.treeRoot}><HasMany {...displayedState.list}>{children}</HasMany></EntitySubTree>)}, ({ children, ...props }) => {return (<HasMany {...props}>{children}</HasMany>)})
usage.tsx
import { Button, Component, SugaredRelativeEntityList } from '@contember/admin'import { HubArticleListingItem } from './HubArticleListingItem'import React, { useMemo, useState } from 'react'import { DynamicHasMany } from './DynamicHasMany'const getArticleProps = (page: number): SugaredRelativeEntityList => {return {field: 'articles[publishedAt <= "now"]',orderBy: 'pinned desc, publishedAt desc',limit: 5,offset: (page - 1) * 5,}}export const ArticlePagination = Component(() => {const [page, setPage] = useState(1)return (<><div className="space-y-8"><DynamicHasMany {...useMemo(() => getArticleProps(page), [page])} ><HubArticleListingItem /></DynamicHasMany></div><div className="flex gap-2 justify-center mt-4"><Button onClick={() => setPage(page - 1)} disabled={page === 1}>Previous page</Button><Button onClick={() => setPage(page + 1)}>Next page</Button></div></>)}, () => {return (<DynamicHasMany {...getArticleProps(1)} ><HubArticleListingItem /></DynamicHasMany>)})
Set field default value in runtime
SetRuntimeDefaults.tsx
import { Component, Field, useEntity } from '@contember/admin'import { useEffect } from 'react'export const SetRuntimeDefaults = Component((values: Record<string, any>) => {const entity = useEntity()useEffect(() => {for (const [field, value] of Object.entries(values)) {entity.getField(field).updateValue(value)}}, [values, entity])return null}, (values: Record<string, any>) => {return <>{Object.entries(values).map(([field, value]) => <Field field={field} />)}</>})
usage.tsx
<SetRuntimeDefaults someField={"value"} someOtherField={1} />
UUID cell
UuidCell.tsx
import { FunctionComponent, ReactNode, useCallback } from 'react'import {Component,DataGridColumn,DataGridColumnPublicProps,DataGridOrderDirection,Field,FieldFallbackView,FieldFallbackViewPublicProps,QueryLanguage,Stack,SugaredRelativeSingleField,Text,wrapFilterInHasOnes,} from '@contember/admin'import { TextInput } from '@contember/ui'import { Input } from '@contember/client'function isUuid(value: string) {return !!value.match(/^[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$/)}export type UuidCellProps =& DataGridColumnPublicProps& FieldFallbackViewPublicProps& SugaredRelativeSingleField& {disableOrder?: booleaninitialOrder?: DataGridOrderDirectionformat?: (value: string | null) => ReactNodeinitialFilter?: UuidFilterArtifacts}export type UuidFilterArtifacts = {mode: 'matchesExactly'query: stringnullCondition: boolean}export type GenericUuidCellFilterArtifacts = {mode: 'matchesExactly'query: string}export const GenericUuidCellFilter = <Filter extends GenericUuidCellFilterArtifacts>({ filter, setFilter }: {filter: FiltersetFilter: (filter: Filter) => void}) => {const [value, setValue] = React.useState(filter.query)return (<><Stack gap={'small'}><TextInputnotNullvalue={filter.query}style={{ minWidth: '350px' }}validationState={value && !isUuid(value) ? 'invalid' : 'valid'}placeholder={'UUID'}onChange={useCallback((currentValue?: string | null) => {if (currentValue === null || currentValue === undefined) {throw new Error('should not happen')}if (currentValue && isUuid(currentValue)) {setFilter({...filter,query: currentValue,})}setValue(currentValue)}, [filter, setFilter, setValue])}/>{(value && !isUuid(value)) && (<Text>This is not valid UUID</Text>)}</Stack></>)}export const createGenericUserCellFilterCondition = (filter: GenericUuidCellFilterArtifacts) => {const baseOperators = {matchesExactly: 'eq',}let condition: Input.Condition<string> = {[baseOperators[filter.mode]]: filter.query,}return condition}export const UuidCell: FunctionComponent<UuidCellProps> = Component(props => {return (<DataGridColumn<UuidFilterArtifacts>{...props}enableOrdering={!props.disableOrder as true}getNewOrderBy={(newDirection, { environment }) =>newDirection ? QueryLanguage.desugarOrderBy(`${props.field as string} ${newDirection}`, environment) : undefined}getNewFilter={(filter, { environment }) => {if (filter.query === '' && filter.nullCondition === false) {return undefined}let condition = filter.query !== '' ? createGenericUserCellFilterCondition(filter) : {}if (filter.nullCondition) {condition = {or: [condition, { isNull: true }],}}const desugared = QueryLanguage.desugarRelativeSingleField(props, environment)return wrapFilterInHasOnes(desugared.hasOneRelationPath, {[desugared.field]: condition,})}}emptyFilter={{mode: 'matchesExactly',query: '',nullCondition: false,}}filterRenderer={({ filter, setFilter, ...props }) => {return (<Stack horizontal align="center"><GenericUuidCellFilter {...props} filter={filter} setFilter={setFilter} /></Stack>)}}><Field<string>{...props}format={value => {if (value === null) {return <FieldFallbackView fallback={props.fallback} fallbackStyle={props.fallbackStyle} />}if (props.format) {return props.format(value as any)}return value}}/></DataGridColumn>)}, 'UuidCell')
ACL defined in content entities
model.ts
import { c } from '@contember/schema-definition'export const memberRole = c.createRole('member')export const personIdVariable = c.createPredefinedVariable('personId', 'personID', memberRole)export class User {name = c.stringColumn().notNull()personId = c.uuidColumn().notNull().unique()teams = c.manyHasMany(Team, 'members')projects = c.oneHasMany(ProjectUserAccess, 'user')}export class Team {name = c.stringColumn().notNull()members = c.manyHasManyInverse(User, 'teams')projects = c.oneHasMany(ProjectTeamAccess, 'team')}@c.Allow(memberRole, {when: {or: [{teams: {team: {members: {personId: personIdVariable,},},},},{members: {user: {personId: personIdVariable,},},},],},read: true,})@c.Allow(memberRole, {when: {or: [{teams: {team: {members: {personId: personIdVariable,},},permissions: { eq: 'write' },},},{members: {user: {personId: personIdVariable,},permissions: { eq: 'write' },},},],},update: true,})export class Project {name = c.stringColumn().notNull()members = c.oneHasMany(ProjectUserAccess, 'project')teams = c.oneHasMany(ProjectTeamAccess, 'project')tasks = c.oneHasMany(Task, 'project')}export const AccessPermission = c.createEnum('read', 'write')export class ProjectUserAccess {project = c.manyHasOne(Project, 'members').notNull().cascadeOnDelete()user = c.manyHasOne(User, 'projects').notNull().cascadeOnDelete()permissions = c.enumColumn(AccessPermission).notNull()}export class ProjectTeamAccess {project = c.manyHasOne(Project, 'teams').notNull().cascadeOnDelete()team = c.manyHasOne(Team, 'projects').notNull().cascadeOnDelete()permissions = c.enumColumn(AccessPermission).notNull()}@c.Allow(memberRole, {when: {project: c.canRead('tasks'),},read: true,})@c.Allow(memberRole, {when: {project: c.canUpdate('tasks'),},delete: true,update: true,create: true,})export class Task {title = c.stringColumn().notNull()description = c.stringColumn().notNull()project = c.manyHasOne(Project, 'tasks')}
Copying content of entities
Often you want to copy some data from another entity. In this guide we will create a copier that copies the content of Page entities.
utils/EntityCopier.ts
The main class for copying entities.
import { EntityAccessor, FieldMarker, HasManyRelationMarker, HasOneRelationMarker, PRIMARY_KEY_NAME } from '@contember/admin'export class EntityCopier {constructor(private handlers: CopyHandler[] = []) {}copy(source: EntityAccessor, target: EntityAccessor) {for (const handler of this.handlers) {if (handler.copy?.({ copier: this, source, target })) {return}}for (const [, marker] of source.getMarker().fields.markers) {if (marker instanceof FieldMarker) {this.copyColumn(source, target, marker)} else if (marker instanceof HasOneRelationMarker) {this.copyHasOneRelation(source, target, marker)} else if (marker instanceof HasManyRelationMarker) {this.copyHasManyRelation(source, target, marker)}}}public copyHasOneRelation(source: EntityAccessor, target: EntityAccessor, marker: HasOneRelationMarker) {for (const handler of this.handlers) {if (handler.copyHasOneRelation?.({ copier: this, source, target, marker })) {return}}const subEntity = source.getEntity({ field: marker.parameters })if (marker.parameters.expectedMutation === 'connectOrDisconnect') {if (!subEntity.existsOnServer && subEntity.hasUnpersistedChanges) {throw 'cannot copy'}target.connectEntityAtField(marker.parameters.field, subEntity)} else if (marker.parameters.expectedMutation === 'anyMutation' ||marker.parameters.expectedMutation === 'createOrDelete') {const subTarget = target.getEntity({ field: marker.parameters })this.copy(subEntity, subTarget)}}public copyHasManyRelation(source: EntityAccessor, target: EntityAccessor, marker: HasManyRelationMarker) {for (const handler of this.handlers) {if (handler.copyHasManyRelation?.({ copier: this, source, target, marker })) {return}}const list = source.getEntityList(marker.parameters)const targetList = target.getEntityList(marker.parameters)if (marker.parameters.expectedMutation === 'connectOrDisconnect') {for (const subEntity of list) {if (!subEntity.existsOnServer && subEntity.hasUnpersistedChanges) {throw 'cannot copy'}targetList.connectEntity(subEntity)}} else if (marker.parameters.expectedMutation === 'anyMutation' ||marker.parameters.expectedMutation === 'createOrDelete') {targetList.disconnectAll()for (const subEntity of list) {targetList.createNewEntity(target => {this.copy(subEntity, target())})}}}public copyColumn(source: EntityAccessor, target: EntityAccessor, marker: FieldMarker) {if (marker.fieldName === PRIMARY_KEY_NAME) {return}for (const handler of this.handlers) {if (handler.copyColumn?.({ copier: this, source, target, marker })) {return}}const sourceValue = source.getField(marker.fieldName).valuetarget.getField(marker.fieldName).updateValue(sourceValue)}}export interface CopyEntityArgs {copier: EntityCopiersource: EntityAccessortarget: EntityAccessor}export interface CopyHasOneRelationArgs {copier: EntityCopiersource: EntityAccessortarget: EntityAccessormarker: HasOneRelationMarker}export interface CopyHasManyRelationArgs {copier: EntityCopiersource: EntityAccessortarget: EntityAccessormarker: HasManyRelationMarker}export interface CopyColumnArgs {copier: EntityCopiersource: EntityAccessortarget: EntityAccessormarker: FieldMarker}export interface CopyHandler {copy?(args: CopyEntityArgs): booleancopyHasOneRelation?(args: CopyHasOneRelationArgs): booleancopyHasManyRelation?(args: CopyHasManyRelationArgs): booleancopyColumn?(args: CopyColumnArgs): boolean}
utils/ContentCopyHandler.ts
This "plugin" regenerates IDs of references.
import { CopyColumnArgs, CopyHandler, CopyHasManyRelationArgs } from './EntityCopier'import { generateUuid, PRIMARY_KEY_NAME } from '@contember/admin'export class ContentCopyHandler implements CopyHandler {constructor(private blockEntity: string, private contentField: string, private referencesField: string) {}copyColumn({ source, marker }: CopyColumnArgs): boolean {return source.name === this.blockEntity && marker.fieldName === this.contentField}copyHasManyRelation({ source, target, copier, marker }: CopyHasManyRelationArgs): boolean {if (source.name !== this.blockEntity || marker.parameters.field !== this.referencesField) {return false}const list = source.getEntityList(marker.parameters)const targetList = target.getEntityList(marker.parameters)targetList.disconnectAll()const referenceMapping = new Map<string, string>()for (const subEntity of list) {const newId = generateUuid()referenceMapping.set(subEntity.id as string, newId)targetList.createNewEntity(target => {copier.copy(subEntity, target())target().getField(PRIMARY_KEY_NAME).updateValue(newId)})}const jsonRaw = source.getField(this.contentField).valueif (typeof jsonRaw !== 'string') {return true}const jsonValue = JSON.parse(jsonRaw, (key, value) => {if (key === 'referenceId') {return referenceMapping.get(value)}return value})target.getField(this.contentField).updateValue(JSON.stringify(jsonValue))return true}}
components/CopyHandler.ts
Helper component, which loads the entity, we are copying from, and loads it into a current entity.
import { Component, EntitySubTree, useEntity, useEntitySubTree } from '@contember/admin'import React, { ReactNode, useEffect, useRef } from 'react'import { EntityCopier } from './EntityCopier'import { ContentCopyHandler } from './ContentCopyHandler'type CopyHandlerProps = { entityName: string; children: ReactNode; parameterName: string }export const CopyHandler = Component<CopyHandlerProps>((props, environment) => {const id = environment.getParameterOrElse(props.parameterName, null)if (!id) {return null}return <CopyHandlerInner {...props} id={id} />})export const CopyHandlerInner = Component<CopyHandlerProps & { id: string | number }>(() => {const entity = useEntity()const sourceEntity = useEntitySubTree('source')const firstRender = useRef(true)useEffect(() => {if (!firstRender.current) {return}firstRender.current = falseconst copier = new EntityCopier([new ContentCopyHandler('ContentBlock', 'json', 'references'), // configure the content to match your schema])copier.copy(sourceEntity, entity)}, [entity, sourceEntity])return null}, (props) => {return (<EntitySubTree entity={{entityName: props.entityName,where: { id: props.id },}} alias={'source'}>{props.children}</EntitySubTree>)})
pages/article.ts
Usage on some page.
import { Component, CreatePage, EditPage, LinkButton } from '@contember/admin'import React from 'react'import { CopyHandler } from './CopyHandler'const ArticleForm = Component(() => {return <>{/*form fields*/}</>})export const create = (<CreatePageentity="Article"rendererProps={{title: 'Create new article',}}redirectOnSuccess={'article/edit(id: $entity.id)'}><ArticleForm /><CopyHandler entityName={'Article'} parameterName={'copyFrom'}><ArticleForm /></CopyHandler></CreatePage>)export const edit = (<EditPage entity="Article(id=$id)" rendererProps={{ title: 'Edit article' }}><ArticleForm /><LinkButton to={'article/create(copyFrom: $entity.id)'}>Create copy</LinkButton></EditPage>)
Button that sets some field null
For example you have a datetime field when you want to contact someone. And want to mark it as done. Easiest way is to just set this field to null. So here's a code to do just that.
SetNullButton.tsx
Put into /components
import { ReactNode } from 'react'import { Button, ButtonProps, SugarableRelativeSingleField, useField, usePersistWithFeedback } from '@contember/admin'export type SetNullButtonProps = {children: ReactNodefield: string | SugarableRelativeSingleFieldimmediatePersist?: boolean} & ButtonPropsexport const SetNullButton = ({ children, field, immediatePersist, ...buttonProps }: SetNullButtonProps) => {const fieldAccessor = useField(field)const triggerPersist = usePersistWithFeedback()if (fieldAccessor.value === null) {return null}return <><Button {...buttonProps} onClick={e => {e.preventDefault()e.stopPropagation()fieldAccessor.getAccessor().updateValue(null)if (immediatePersist) {triggerPersist().catch(() => { })}}}>{children}</Button></>}
AnyFile.tsx
Usage of the component above
...<SetNullButton field="nextContactDate" immediatePersist>Mark done</SetNullButton>...
Using @contember/admin inside Next.js app
By integrating Contember Admin into your Next.js app, you can efficiently manage and update data, delivering seamless user experiences. However, there are a few considerations to keep in mind. Contember Admin is client-side, so server-side rendering (SSR) needs to be disabled for that component. Additionally, a public token is required to access Contember Admin. In this guide, we'll walk through the necessary steps to set up Contember Admin within Next.js, overcoming these limitations.
Also keep in mind, that Contember CSS may collide with other CSS frameworks, like Tailwind.
Also keep in mind, that Contember CSS may collide with other CSS frameworks, like Tailwind.
next.config.js
In next config, we need to transpile the @contember/admin package. Also, we disable react strict mode, as there are currently some issues with some components.
const withTM = require('next-transpile-modules')(['@contember/admin'])/** @type {import('next').NextConfig} */const nextConfig = {reactStrictMode: false,}module.exports = withTM(nextConfig)
components/SubmitGist.tsx
Here is a sample component used in Next.js page. We must first setup some contexts.
import { ContemberClient, CreateScope, I18nProvider, PersistButton, RichTextField, TextField, ToasterProvider } from '@contember/admin'export const SubmitGist = () => {return (<I18nProvider localeCode={undefined} dictionaries={undefined}><ToasterProvider><ContemberClientapiBaseUrl={process.env.NEXT_PUBLIC_CONTEMBER_BASE_URL!}project={process.env.NEXT_PUBLIC_CONTEMBER_PROJECT}stage={process.env.NEXT_PUBLIC_CONTEMBER_STAGE}sessionToken={process.env.NEXT_PUBLIC_CONTEMBER_TOKEN}><CreateScope entity={'Gist'}><TextField field="title" label="Gist title" required /><RichTextField field="contentJson" label="Gist description" /><PersistButton /></CreateScope></ContemberClient></ToasterProvider></I18nProvider>)}
pages/submit.tsx
To force client side rendering, we need to wrap our Contember component like this:
import dynamic from 'next/dynamic'const NoSsrWrapper = dynamic(() => import('../components/SubmitGist').then(it => it.SubmitGist),{ ssr: false },)export default () => {return <><NoSsrWrapper /></>}
pages/_app.tsx
To include @contember/admin CSS, add them to Next application entrypoint
// ....import '@contember/admin/style.css'// ....
style/global.css
Here are some, but not all, fixes to solve compatibility issues with a tailwind.
body, html {display: initial;min-width: initial;}* {border-color: initial;}.cui-textarea-input, .cui-text-input {padding-left: var(--cui-padding-horizontal);padding-right: var(--cui-padding-horizontal);}.gists-submit .cui-repeater-item-container-label {display: none;}.cui-textarea-input {padding-bottom: var(--cui-text-area-padding-vertical);padding-top: var(--cui-text-area-padding-vertical);}
.env.local
Sample env file. Keep in mind that the token will be exposed publicly, so make sure your ACL is correct
NEXT_PUBLIC_CONTEMBER_BASE_URL=http://localhost:1481NEXT_PUBLIC_CONTEMBER_PROJECT=my-projectNEXT_PUBLIC_CONTEMBER_STAGE=liveNEXT_PUBLIC_CONTEMBER_TOKEN=0000000000000000000000000000000000000000
Connect entity
This component allows you to connect an entity by a using a unique identifier, such as from a URL. It can be used in conjunction with SelectField, as SelectField does not have the capability to select default values.
ConnectEntity.tsx
During the static render, the component retrieves the entity you wish to connect. Subsequently, in the useEffect, the said entity gets linked to the specified field.
export interface ConnectEntityProps {entity: stringwhere: SugaredUniqueWhere | undefinedfield: stringchildren?: ReactNode}export const ConnectEntity = Component<ConnectEntityProps>(({ entity, where, field }) => {const currentEntity = useEntity()const getSubtree = useGetEntitySubTree()useEffect(() => {if (!where || currentEntity.getEntity(field).existsOnServer) {return}const entityToConnect = getSubtree({entity: {entityName: entity,where,},})if (entityToConnect) {currentEntity.connectEntityAtField(field, entityToConnect)}}, [entity, field, currentEntity, where, getSubtree])return null}, ({ entity, where, children, field }) => {if (!where) {return null}return <><HasOne field={field} /><EntitySubTree entity={{ entityName: entity, where }} children={children} /></>})
Usage.tsx
The usage is straightforward. You simply need to specify the field where you want to connect the entity and provide the unique identifier of the entity.
<ConnectEntity field={'site'} entity={'Site'} where={'(code = $site)'} />