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 | string
list: 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.current
const 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?: boolean
initialOrder?: DataGridOrderDirection
format?: (value: string | null) => ReactNode
initialFilter?: UuidFilterArtifacts
}
export type UuidFilterArtifacts = {
mode: 'matchesExactly'
query: string
nullCondition: boolean
}
export type GenericUuidCellFilterArtifacts = {
mode: 'matchesExactly'
query: string
}
export const GenericUuidCellFilter = <Filter extends GenericUuidCellFilterArtifacts>({ filter, setFilter }: {
filter: Filter
setFilter: (filter: Filter) => void
}) => {
const [value, setValue] = React.useState(filter.query)
return (
<>
<Stack gap={'small'}>
<TextInput
notNull
value={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).value
target.getField(marker.fieldName).updateValue(sourceValue)
}
}
export interface CopyEntityArgs {
copier: EntityCopier
source: EntityAccessor
target: EntityAccessor
}
export interface CopyHasOneRelationArgs {
copier: EntityCopier
source: EntityAccessor
target: EntityAccessor
marker: HasOneRelationMarker
}
export interface CopyHasManyRelationArgs {
copier: EntityCopier
source: EntityAccessor
target: EntityAccessor
marker: HasManyRelationMarker
}
export interface CopyColumnArgs {
copier: EntityCopier
source: EntityAccessor
target: EntityAccessor
marker: FieldMarker
}
export interface CopyHandler {
copy?(args: CopyEntityArgs): boolean
copyHasOneRelation?(args: CopyHasOneRelationArgs): boolean
copyHasManyRelation?(args: CopyHasManyRelationArgs): boolean
copyColumn?(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).value
if (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 = false
const 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 = (
<CreatePage
entity="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: ReactNode
field: string | SugarableRelativeSingleField
immediatePersist?: boolean
} & ButtonProps
export 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.

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>
<ContemberClient
apiBaseUrl={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:1481
NEXT_PUBLIC_CONTEMBER_PROJECT=my-project
NEXT_PUBLIC_CONTEMBER_STAGE=live
NEXT_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: string
where: SugaredUniqueWhere | undefined
field: string
children?: 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)'} />