Creating a custom Bryntum Calendar React component for Storyblok
Bryntum Calendar is a powerful and flexible JavaScript scheduling component that easily integrates with all major JavaScript frontend frameworks.
This guide demonstrates how to add the Bryntum Calendar React component to a Storyblok project. Storyblok is a headless CMS known for its real-time visual editing capabilities.
We’ll do the following:
- Setting up a Next.js project with a Bryntum Calendar component.
- Creating a new Storyblok space for content management.
- Defining Storyblok blocks to render the Bryntum Calendar using the visual editor.
- Creating custom event blocks in Storyblok to manage calendar event data.
- Integrating Storyblok into the Next.js application.
- Building React components that map to Storyblok blocks.
- Configuring the Bryntum Calendar to fetch event data from Storyblok and sync changes back using the Storyblok Management API.
Here’s a preview of what you’ll build:
You can find the code for the completed tutorial in this GitHub repository.
Creating a Next.js Bryntum Calendar project
We’ll use the Bryntum Calendar Next.js with TypeScript starter template. Follow the instructions in the template’s README file to install the required dependencies.
The Calendar component located in components/Calendar.tsx
is a React Client Component, as indicated by the "use client"
directive at the top of the file.
The Calendar configuration, which is stored in the calendarProps
variable, uses the Bryntum Calendar’s CrudManager
to load example event data from a local data.json
file.
The code inside the useEffect
function shows how to access the Bryntum Calendar instance.
To ensure compatibility with Next.js, the Bryntum Calendar React component is dynamically imported in components/CalendarWrapper.tsx
with server-side rendering (SSR) disabled. This is required because Bryntum components are client-side only.
Getting started with Storyblok
Sign up to Storyblok to get a 14-day free trial.
The Storyblok Visual Editor lets users manage and organize website content without writing code. We’ll create React components for Storyblok that you can drag and drop in the visual editor, while giving developers fine control over how content is rendered.
In Storyblok, a space is where all content related to a project is kept. The content of a page is stored as a story, made up of content entries defined by a content type, such as “Article page”. Content types have various data fields, such as text, number, boolean, and asset. A field can also be a block type. A Storyblok block is a reusable content component.
There are three types of blocks (components): content type, nestable, and universal. Nestable blocks are nested within a story or other block, for example, “Chapter”, “Section”, or “Full Width Image”. Because nestable blocks are part of a story, you can’t create a story from this block type. A universal block can be used as a content type block and a nestable block at the same time.
Storyblok gets its name from this component structure.
Creating a Storyblok space
Once you’ve logged in to Storyblok, select Explore Demo in the Get started with Storyblok section to open a demo space and take a quick guided tour.
After exploring the demo, open the Spaces page by clicking the Your demo space link at the top left. You’ll see the demo space in the My Spaces section. Click the + Add Space button at the top right to create a new space.
For this tutorial, we’ll use the Starter plan. Click the Continue with Starter button.
Name your space “bryntum-calendar” and click the Create a Space button.
A Quickstart card will appear for your new space.
Open the Content page from the left sidebar. You’ll see that a “Home” story has been created automatically. We’ll use this story. Click on it to open the Visual Editor.
In the right panel of the visual editor, you can see the example content that’s been added to the story.
Copy the access token displayed in the content preview.
In your Next.js project, create a .env.local
file and add the access token as a NEXT_PUBLIC_STORYBLOK_API_TOKEN
environment variable.
Now click Blocks in the left navigation bar to open the Block library. Note the four example blocks added to the story. Hover over a block to reveal a three-dot menu on the right-hand side. Click the three dots to open the menu, and select Delete to delete the block. Use this method to delete the feature, grid, and teaser blocks.
Creating Bryntum Calendar Storyblok blocks
We’ll create four blocks for the Bryntum Calendar page: Header, Calendar, Event, and Resource.
In the Block library, click the + New Block button at the top right. Set the Technical name to “header”, choose Nestable block as the type, and click Add Block.
Click the header block in the block library to open the block editor. Add a field named “title”, set its type to Text, and save.
Create another nestable block called “resource”. This block stores the Bryntum Calendar resources data. Add the following fields based on the Bryntum Calendar ResourceModel
fields.
Field Name | Field Type |
---|---|
id | Text |
name | Text |
eventColor | Text |
readOnly | Boolean |
In the “resource” block editor, click the readOnly list item in the General tab to open the Edit field tab and toggle on the Default value switch.
Next, create a nestable block called “event”. This block will store the Bryntum Calendar event data. Add the following fields based on the Bryntum Calendar EventModel
fields.
Field Name | Field Type |
---|---|
id | Text |
name | Text |
readOnly | Boolean |
resourceId | Text |
draggable | Boolean |
resizable | Boolean |
allDay | Boolean |
startDate | Date/Time |
endDate | Date/Time |
exceptionDates | Text |
recurrenceRule | Text |
cls | Text |
eventColor | Text |
eventStyle | Text |
iconCls | Text |
style | Text |
For the draggable and resizable fields, toggle on the Default value switch.
For simplicity, we’ve excluded some fields and other Bryntum Calendar data stores like assignments and dependencies.
Next, create a “calendar” block with a Universal block type. Add two fields of type Blocks, one named “resources” and one named “events”. Click Save.
Close the Block library modal.
In the Page block panel, delete the Teaser and Grid blocks from the Body field.
Click the Save button at the top right of the page.
Adding the Storyblok components to a page
We’ll now add the Storyblok components for a Bryntum Calendar to your page component and populate them with content.
First, click the Add Block button in the Body field of the page component and insert a Header block.
Enter “Team Hackathon Calendar” into the header block’s Title field, then click Save at the top right.
Add a Calendar block below the header block. Inside the Calendar block’s Resources field, add three Resource blocks with the following data:
Field Name | Resource 1 | Resource 2 | Resource 3 |
---|---|---|---|
id | bryntum | hotel | michael |
name | Bryntum team | Hotel Park | Michael Johnson |
eventColor | blue | orange | deep-orange |
Click Save.
Next, add three Event blocks to the Events field of the Calendar block using this data:
Field Name | Event 1 | Event 2 | Event 3 |
---|---|---|---|
id | 1 | 2 | 3 |
name | Breakfast | Roadmapping for 2026 | Check-Out & Fly home |
resourceId | hotel | bryntum | michael |
startDate | 2025-10-21 09:00 | 2025-10-22 10:00 | 2025-10-24 10:00 |
endDate | 2025-10-21 10:00 | 2025-10-22 12:00 | 2025-10-24 12:00 |
Click Save.
Setting up a dev server
Since Storyblok V2, you need to set up your dev server with an HTTPS proxy to enable a secure connection for the application. We’ll use port 3010, so your Next.js app will be accessible at https://localhost:3010.
Follow the Storyblok guide for your operating system to set up an HTTPS proxy:
Next, set the Visual Editor to open at your HTTPS proxy URL. Exit the visual editor by hovering over the Storyblok logo at the top left and clicking the back button that appears. Open the Settings menu for your “bryntum-calendar” space, go to the Visual Editor configuration, and set the Location to https://localhost:3010
. Click Save at the top right.
Setting the real path
To display your Next.js app in the Storyblok Visual Editor, you’ll need to change the real path. The real path determines where the visual editor opens if the location is different from the default “home” slug.
Go to the Content page and click on the Home story to open the visual editor. Click Config in the menu on the right, set Real path to “/”, and save.
Click Edit in the menu on the right. Make sure both your Next.js development server and HTTPS proxy are running.
You should now see the example Bryntum Calendar in the visual editor.
At this point, the Bryntum Calendar isn’t using Storyblok data yet. Next, you’ll connect Storyblok to your Next.js app to fetch the story data, and then create React components to render it.
Connecting Storyblok to the Next.js project
First, install the official Storyblok React SDK.
npm install @storyblok/react
This React plugin allows you to interact with the Storyblok API and enables real-time visual editing.
In the app/components
folder, create a file called StoryblokProvider.tsx
and add the following code:
'use client';
import { apiPlugin, storyblokInit } from '@storyblok/react';
import { ReactNode } from 'react';
storyblokInit({
accessToken : process.env.NEXT_PUBLIC_STORYBLOK_API_TOKEN,
use : [apiPlugin]
});
export default function StoryblokProvider({
children
}: {
children: ReactNode;
}) {
return children;
}
The storyblokInit
function initializes the connection to Storyblok. Note that if your Storyblok space server location is set to a region other than Europe, you’ll need to add a region parameter to the storyblokInit
function call to set the correct region.
Import StoryblokProvider
into app/layout.tsx
:
import StoryblokProvider from "./components/StoryblokProvider";
Wrap the returned React elements in StoryblokProvider
:
return (
<StoryblokProvider>
<html lang="en" className={poppins.className}>
<body>
{children}
</body>
</html>
</StoryblokProvider>
);
This wrapper ensures the Storyblok components are loaded client-side so you can see real-time updates. The Bryntum Calendar is also client-side only.
Fetching the story data
The next step is to fetch your story data from Storyblok and enable real-time editing in the Visual Editor.
The useStoryblok
hook fetches story content and enables live editing by automatically updating when content changes in the Visual Editor.
We’ll store the story data in React context. Let’s create the provider.
Create a contexts
folder in app
. In contexts
, create a StoryData.context.tsx
file and add the following lines of code to it:
'use client';
import { Page } from '@/.storyblok/types/storyblok-components';
import { getStoryblokApi } from '@storyblok/react';
import { createContext, Dispatch, SetStateAction, useState } from 'react';
export type Story = {
content: Page;
[index: string]: unknown;
};
export const StoryDataContext = createContext({
storyData : {} as Story,
setStoryData : (() => {}) as Dispatch<SetStateAction<Story>>,
invalidateCache : (() => {}) as () => void,
refreshStory : (() => {}) as () => void,
cacheKey : 0 as number
});
export default function StoryDataProvider({
children
}: {
children: React.ReactNode;
}) {
const [storyData, setStoryData] = useState<Story>({} as Story);
const [cacheKey, setCacheKey] = useState(0);
const invalidateCache = () => {
const storyblokApi = getStoryblokApi();
storyblokApi.flushCache();
};
const refreshStory = () => {
invalidateCache();
setCacheKey(Date.now());
};
return (
<StoryDataContext.Provider
value={{
storyData,
setStoryData,
invalidateCache,
refreshStory,
cacheKey
}}
>
{children}
</StoryDataContext.Provider>
);
}
The refreshStory
function invalidates the Storyblok API cache and sets the cache version to the current time, ensuring you always get fresh data. You can learn more about caching in this Storyblok article: How stories are cached in the Content Delivery API.
In app/layout.tsx
, import the story data context provider:
import StoryDataProvider from "./contexts/StoryData.context";
In the previously added StoryblokProvider
, wrap the returned HTML in StoryDataProvider
:
return (
<StoryblokProvider>
<StoryDataProvider>
<html lang="en" className={poppins.className}>
<body>
{children}
</body>
</html>
</StoryDataProvider>
</StoryblokProvider>
);
Replace the code in app/page.tsx
file with the following:
'use client';
import { Page } from '@/.storyblok/types/storyblok-components';
import { StoryblokComponent, useStoryblok } from '@storyblok/react';
import { useContext, useEffect } from 'react';
import { Story, StoryDataContext } from './contexts/StoryData.context';
import { CustomEventModel } from './lib/CustomEventModel';
export default function Home() {
const { setStoryData, cacheKey } = useContext(StoryDataContext);
const story = useStoryblok('/home', { version : 'draft', cv : cacheKey });
useEffect(() => {
if (!story?.content) return;
const transformedStory: Story = {
...story,
content : {
...story.content,
component : 'page',
_uid : story.content._uid || '',
body : story.content.body?.map((block: Page) => {
if (block.events) {
return {
...block,
events : (block.events as CustomEventModel[]).map((event: CustomEventModel) => {
event.exceptionDates = event?.exceptionDates ?
(typeof event.exceptionDates === 'string' ?
Object.keys(JSON.parse(event.exceptionDates)) :
event.exceptionDates) :
[];
// Filter out empty string fields
return Object.fromEntries(
Object.entries(event).filter(([, value]) => value !== '')
);
})
};
}
return block;
})
}
};
setStoryData(transformedStory);
}, [story, setStoryData]);
if (!story || !story.content) {
return <div>Loading...</div>;
}
return <StoryblokComponent blok={story.content} />;
}
The useStoryblok
hook fetches the story data and takes two arguments: the story slug and API options (here we’re fetching the "draft"
version of the content). The cv
is the cache version. By default, the Storyblok React SDK client reuses the cached value for Storyblok API calls so that cached data is returned from the Storyblok CDN, which saves on API calls and improves performance. We’ll prevent caching of API responses by providing new cv
values for each API call.
The story data has empty string fields filtered out from the events and resources data, and then the storyData
is stored in context using setStoryData
.
We pass the story data as a prop to StoryblokComponent
, which dynamically renders the appropriate React components.
Creating React components for the Storyblok blocks
Next, let’s create React components for the Storyblok blocks.
In your components
folder, create a Page.tsx
file and add the following code:
import { Page as PageType } from '@/.storyblok/types/storyblok-components';
import { SbBlokData, StoryblokComponent, storyblokEditable } from '@storyblok/react';
const Page = ({ blok }: { blok: PageType & SbBlokData }) => {
return (
<main {...storyblokEditable(blok)}>
{blok.body?.map((nestedBlok) => (
<StoryblokComponent blok={nestedBlok} key={nestedBlok._uid} />
))}
</main>
);
};
export default Page;
The storyblokEditable
function links editable components to the Storyblok Visual Editor for inline editing. The StoryblokComponent
dynamically renders the Storyblok blocks.
Now, in the same folder, create a Header.tsx
file:
import { Header as HeaderType } from '@/.storyblok/types/storyblok-components';
import { SbBlokData, storyblokEditable } from '@storyblok/react';
const Header = ({ blok }: { blok: HeaderType & SbBlokData }) => {
return (
<h2 {...storyblokEditable(blok)} style={{ padding : '1rem' }}>
{blok.title}
</h2>
);
};
export default Header;
Next, create a CalendarSb.tsx
file:
import { SbBlokData, storyblokEditable } from '@storyblok/react';
import { Calendar } from '@/.storyblok/types/storyblok-components';
import { CalendarWrapper } from './CalendarWrapper';
const CalendarSb = ({ blok }: { blok: Calendar & SbBlokData }) => {
return (
<div {...storyblokEditable(blok)} style={{ flex : 1 }}>
<CalendarWrapper />
</div>
);
};
export default CalendarSb;
The Bryntum Calendar is rendered using the CalendarWrapper
component.
Finally, make sure to import these components into your components/StoryblokProvider.tsx
file, and register them in the components
object for storyblokInit
:
import Page from "./Page";
import Header from "./Header";
import CalendarSb from "./CalendarSb";
const components = {
page : Page,
header : Header,
calendar : CalendarSb
};
storyblokInit({
accessToken : process.env.NEXT_PUBLIC_STORYBLOK_API_TOKEN,
use : [apiPlugin],
components
});
Adding Storyblok types to components
The Storyblok components you created use types from a component-types-sb.d.ts
file generated from your Storyblok schema. To generate these types, you’ll use the Storyblok CLI.
First, install the Storyblok CLI globally:
npm i -g storyblok
Log in to Storyblok via the CLI:
storyblok login
Add the following scripts to your package.json
(replace 285757208479996
with your actual Storyblok space ID):
"pull-sb-components": "storyblok components pull --space 285757208479996",
"generate-sb-types": "storyblok types generate --space 285757208479996"
Find your space ID in the Settings page:
Download the schema of your Storyblok components:
npm run pull-sb-components
A components.json
file will appear in the .storyblok
directory.
Now generate TypeScript types based on the downloaded schema:
npm run generate-sb-types
This will create a types
folder in .storyblok
. Move the generated storyblok-components.d.ts
file up one level to the types
folder, then delete the folder named after your space ID.
⚠️ Whenever you make changes to your blocks in Storyblok, rerun the pull-sb-components
and generate-sb-types
scripts to update your TypeScript types.
Loading the Storyblok story data into the Bryntum Calendar
Now, let’s load the Storyblok story data into the Bryntum Calendar using your custom models.
First, update app/calendarConfig.ts
as follows:
import { BryntumCalendarProps } from '@bryntum/calendar-react';
import { CustomEventModel } from './lib/CustomEventModel';
import { CustomResourceModel } from './lib/CustomResourceModel';
const calendarProps: BryntumCalendarProps = {
date : new Date(2025, 9, 20),
project : {
eventStore : {
modelClass : CustomEventModel
},
resourceStore : {
modelClass : CustomResourceModel
}
}
};
export { calendarProps };
Here, CustomEventModel
and CustomResourceModel
are used to add Storyblok-specific fields to the Bryntum Calendar events and resources data.
In app
, create a lib
folder. In lib
, create a CustomEventModel.ts
file:
import { EventModel } from '@bryntum/calendar';
export interface CustomEventModelType extends EventModel {
_editable?: string;
component?: string;
_uid?: string;
}
export class CustomEventModel extends EventModel implements CustomEventModelType {
static get fields() {
return [
{ name : 'component', type : 'string', defaultValue : '' },
{ name : '_uid', type : 'string', defaultValue : '' },
{ name : '_editable', type : 'string', defaultValue : '' }
];
}
}
And create a CustomResourceModel.ts
file:
import { ResourceModel } from '@bryntum/calendar';
export interface CustomResourceModelType extends ResourceModel {
_editable?: string;
component?: string;
_uid?: string;
}
export class CustomResourceModel extends ResourceModel {
static get fields() {
return [
{ name : 'component', type : 'string', defaultValue : '' },
{ name : '_uid', type : 'string', defaultValue : '' },
{ name : '_editable', type : 'string', defaultValue : '' }
];
}
}
These custom models ensure that fields like _uid
and component
are available to Bryntum Calendar and mapped from Storyblok data.
Now, update your app/components/Calendar.tsx
file to connect the Bryntum Calendar to Storyblok data via React context and your custom models.
Replace the code in app/components/Calendar.tsx
with:
'use client';
import { BryntumCalendar } from '@bryntum/calendar-react';
import { useContext, useEffect, useRef } from 'react';
import { StoryDataContext } from '../contexts/StoryData.context';
import { calendarProps } from '../calendarConfig';
import { CustomEventModelType } from '../lib/CustomEventModel';
import { CustomResourceModelType } from '../lib/CustomResourceModel';
export default function Calendar() {
const { storyData, setStoryData, refreshStory, invalidateCache } = useContext(StoryDataContext);
const currCalendarComponentIndex = storyData?.content?.body?.findIndex(
(item) => item.hasOwnProperty('events')
);
const calendarRef = useRef<BryntumCalendar>(null);
useEffect(() => {
// Bryntum Calendar instance
const calendar = calendarRef?.current?.instance;
}, []);
return (
<BryntumCalendar
ref={calendarRef}
events={currCalendarComponentIndex !== undefined ? (storyData?.content?.body?.[currCalendarComponentIndex]?.events as CustomEventModelType[]) || [] : []}
resources={currCalendarComponentIndex !== undefined ? (storyData?.content?.body?.[currCalendarComponentIndex]?.resources as CustomResourceModelType[]) || [] : []}
{...calendarProps}
/>
);
}
Here, we get the story data from the StoryDataContext
provider using the useContext
hook. We then bind the events and resources data to the Bryntum Calendar React component. When the storyData
state changes, the Bryntum Calendar will be updated.
We use the currCalendarComponentIndex
variable to find the Calendar component in the story data, assuming that there’s only one Calendar in the story.
Finally, update your global styles to ensure the Bryntum Calendar displays correctly.
Replace the styles in the app/globals.css
file with the following:
* {
box-sizing: border-box;
padding: 0;
margin: 0;
}
#b-calendar-1 {
display: flex;
flex-direction: column;
height: 100vh;
font-size: 14px;
}
With these styles in place, the Bryntum Calendar will fill the viewport and maintain a consistent appearance.
At this point, when you make changes to the story content in the Visual Editor, you’ll see those changes reflected in the Bryntum Calendar:
However, updates made directly in the Bryntum Calendar (such as adding, editing, or deleting events/resources) won’t yet persist back to Storyblok. The next step is to enable two-way syncing by connecting the Bryntum Calendar’s data changes to the Storyblok Management API.
Updating Storyblok story data from the Bryntum Calendar
You’ll use the Storyblok Management API to update the Storyblok story data from the Bryntum Calendar, and you need a personal access token to authenticate. To create it, go to the Personal access token tab in the Storyblok account page and click Generate new token.
Add the following environment variables to your Next.js app’s .env.local
file:
STORYBLOK_BRYNTUM_CALENDAR_SPACE_ID=
STORYBLOK_BRYNTUM_CALENDAR_STORY_ID=
STORYBLOK_PERSONAL_ACCESS_TOKEN=
STORYBLOK_BRYNTUM_CALENDAR_SPACE_ID
is the space ID you retrieved when we added Storyblok types to components.
STORYBLOK_BRYNTUM_CALENDAR_STORY_ID
is your story ID. Find it by navigating to the Visual Editor, clicking More Options in the top-right corner, and selecting Draft JSON from the dropdown.
Your story ID will appear in the data returned from the Storyblok API.
STORYBLOK_PERSONAL_ACCESS_TOKEN
is your personal access token.
Creating a Next.js API route to update data
To update the Storyblok story from the Bryntum Calentar, you need to create a Next.js API route.
In app
, create an api
folder. In api
, create an update
folder. In update
, create a route.ts
file and add the following PUT
request route handler:
export async function PUT(request: Request): Promise<Response> {
const reqBody = await request.json();
try {
const requestPayload = {
story : reqBody.story,
force_update : 1, // Force update to avoid conflicts
publish : 1 // Auto-publish when working with published version
};
const res = await fetch(
`https://mapi.storyblok.com/v1/spaces/${process.env.STORYBLOK_BRYNTUM_CALENDAR_SPACE_ID}/stories/${process.env.STORYBLOK_BRYNTUM_CALENDAR_STORY_ID}`,
{
method : 'PUT',
headers : {
Authorization : process.env.STORYBLOK_PERSONAL_ACCESS_TOKEN || '',
'Content-Type' : 'application/json'
},
body : JSON.stringify(requestPayload)
}
);
const data = await res.json();
return Response.json(data);
}
catch (error) {
console.error('Loading events and resources data failed', error);
return new Response('Loading events and resources data failed', {
status : 500
});
}
}
You are using the Storyblok Management API to update the Storyblok story. In the request body properties, set force_update
and publish
to 1
. This will force update the story and publish it immediately.
Updating the Bryntum Calendar component
Next, let’s set up the Bryntum Calendar to send event and resource updates to the API route. We’ll use the Bryntum Calendar onDataChange
event to detect when the store data has changed.
In app/components/Calendar.tsx
, add the following onDataChange
property to the BryntumCalendar
component:
onDataChange = { syncData }
Add the following syncData
function in the Calendar
component:
const syncData: SyncData = ({ store, action, record, records }) => {
const storeId = store.id;
if (storeId === 'events') {
const eventRecord = record as CustomEventModelType;
if (action === 'remove') {
const storyDataState: Story = JSON.parse(JSON.stringify(storyData));
const content = storyDataState.content;
const updatedContent = content?.body?.map((item) => {
if (item.component === 'calendar') {
records.forEach((evt) => {
item.events = item.events?.filter(
(event) => event.id !== evt.id
);
});
}
return item;
});
const updatedStory = {
story : {
...storyDataState,
content : {
...storyData.content,
body : updatedContent
}
}
};
updateStory(updatedStory);
}
if (action === 'update') {
const storyDataState: Story = JSON.parse(JSON.stringify(storyData));
const content = storyDataState.content;
const updatedContent = content.body?.map((item) => {
if (item.component === 'calendar') {
const existingEventIndex = item.events?.findIndex(
(event) => event.id === eventRecord.id
);
const eventData = {
id : existingEventIndex >= 0 ? eventRecord.id : crypto.randomUUID(),
_uid : existingEventIndex >= 0 ? eventRecord.get('_uid') : crypto.randomUUID(),
name : eventRecord.name,
startDate : convertDateToSbFormat(`${eventRecord.startDate}`),
endDate : convertDateToSbFormat(`${eventRecord.endDate}`),
component : 'event',
resourceId : eventRecord.resourceId,
readOnly : eventRecord.readOnly || false,
draggable : eventRecord.draggable !== false,
resizable : eventRecord.resizable !== false,
allDay : eventRecord.allDay || false,
exceptionDates : eventRecord.exceptionDates ? JSON.stringify(eventRecord.exceptionDates) : undefined,
recurrenceRule : eventRecord.recurrenceRule,
cls : eventRecord.cls,
eventColor : eventRecord.eventColor,
eventStyle : eventRecord.eventStyle,
iconCls : eventRecord.iconCls,
style : eventRecord.style
};
if (item.events && existingEventIndex >= 0) {
// Update existing event
item.events[existingEventIndex] = eventData as Event;
}
else {
// Create new event (first update after add)
item.events?.push(eventData as Event);
}
}
return item;
});
const updatedStory = {
story : {
...storyDataState,
content : {
...storyData.content,
body : updatedContent
}
}
};
updateStory(updatedStory);
}
}
if (storeId === 'resources') {
const resourceRecord = record as CustomResourceModelType;
if (action === 'remove') {
const storyDataState: Story = JSON.parse(JSON.stringify(storyData));
const content = storyDataState.content;
const updatedContent = content?.body?.map((item) => {
if (item.component === 'calendar') {
records.forEach((res) => {
item.resources = item.resources?.filter(
(resource) => resource.id !== res.id
);
});
}
return item;
});
const updatedStory = {
story : {
...storyDataState,
content : {
...storyData.content,
body : updatedContent
}
}
};
updateStory(updatedStory);
}
if (action === 'update') {
const storyDataState: Story = JSON.parse(JSON.stringify(storyData));
const content = storyDataState.content;
const updatedContent = content?.body?.map((item) => {
if (item.component === 'calendar') {
const existingResourceIndex = item.resources?.findIndex(
(resource) => resource.id === resourceRecord.id
);
const resourceData = {
id : resourceRecord.id,
_uid : resourceRecord.get('_uid') || crypto.randomUUID(),
name : resourceRecord.name,
eventColor : resourceRecord.eventColor,
readOnly : resourceRecord.readOnly || false,
component : 'resource'
};
if (item.resources && existingResourceIndex && existingResourceIndex >= 0) {
// Update existing resource
item.resources[existingResourceIndex] = resourceData as Resource;
}
else {
// Create new resource (first update after add)
item.resources?.push(resourceData as Resource);
}
}
return item;
});
const updatedStory = {
story : {
...storyDataState,
content : {
...storyData.content,
body : updatedContent
}
}
};
updateStory(updatedStory);
}
}
};
We get the store
, action
, record
, records
, and changes
data from the dataChange
event parameters. The store
is used to determine which data store has been changed. The action
determines the type of data change, "remove"
or "update"
.
We don’t handle the "add"
action when an event is created. Instead, we create the event when it’s first updated. When an event is created in the Bryntum Calendar, an "add"
event occurs and the event editor popup opens. When the new calendar event is saved, an "update"
action is triggered and the event is created.
For each action, we update the storyData
state object and pass the updated story object to the updateStory
function. This function will call the update API.
Add the following updateStory
function definition above the syncData
function:
function updateStory(updatedStory: UpdatedStory) {
delete updatedStory.story.content._editable;
const currCalendarComponentIndex = updatedStory.story?.content?.body?.findIndex(
(item) => item.hasOwnProperty('events')
);
if (!currCalendarComponentIndex) return;
updatedStory.story.content.body?.map((item) => {
delete item._editable;
});
if (updatedStory.story.content.body?.[currCalendarComponentIndex]?.events) {
updatedStory.story.content.body[currCalendarComponentIndex].events =
(updatedStory.story.content.body[currCalendarComponentIndex].events as CustomEventModelType[]).map((event: CustomEventModelType) => {
delete event._editable;
// Normalize exceptionDates to prevent Bryntum errors
if (typeof event.exceptionDates === 'string' || event.exceptionDates === null) {
event.exceptionDates = [];
}
return event;
});
}
if (updatedStory?.story?.content?.body?.[currCalendarComponentIndex]?.resources) {
updatedStory.story.content.body[currCalendarComponentIndex].resources =
(updatedStory.story.content.body[currCalendarComponentIndex].resources as CustomResourceModelType[]).map((resource: CustomResourceModelType) => {
delete resource._editable;
return resource;
});
}
// Optimistically update the state immediately to prevent stale data
setStoryData(updatedStory.story);
fetch('/api/update', {
method : 'PUT',
headers : {
'Content-Type' : 'application/json'
},
body : JSON.stringify(updatedStory)
})
.then((response) => response.json())
.then(() => {
// State already updated optimistically, just invalidate cache
invalidateCache();
})
.catch((error) => {
console.error('Error:', error);
// Rollback on failure by refreshing from server
refreshStory();
});
}
If the update succeeds, the storyData
state will be set to the new story data.
At the top of the file, add the following types for the syncData
and updateStory
functions:
type SyncData = ((event: {
source: typeof ProjectConsumer;
project: typeof ProjectModelMixin;
store: Store;
action: 'remove' | 'removeAll' | 'add' | 'clearchanges' | 'filter' | 'update' | 'dataset' | 'replace';
record: Model | CustomEventModelType | CustomResourceModelType;
records: Model[] | CustomEventModelType[] | CustomResourceModelType[];
changes: object;
}) => void) | string
type UpdatedStory = {
story: Story;
}
And the following imports:
import { Event, Resource } from '@/.storyblok/types/storyblok-components';
import { Model, ProjectConsumer, ProjectModelMixin, Store } from '@bryntum/calendar';
import { Story, StoryDataContext } from '../contexts/StoryData.context';
import { convertDateToSbFormat } from '../helpers';
Then, create a helpers.ts
file in the app
folder and add the following convertDateToSbFormat
function:
export function convertDateToSbFormat(dateString: string): string {
// Parse the input date string
const date = new Date(dateString);
// Extract year, month, day, hours, and minutes
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const day = date.getDate().toString().padStart(2, '0');
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
// Construct the output date string in the desired format
const formattedDate = `${year}-${month}-${day} ${hours}:${minutes}`;
return formattedDate;
}
This function converts a JavaScript date to a date string properly formatted for Storyblok.
Now, when you update an event or resource in the Bryntum Calendar, the story data in Storyblok will be updated. If you make changes to the Bryntum Calendar in the Storyblok Visual Editor and click Save, you’ll see a Content Conflict dialog with the following message:
A newer version of this content item has been found in the database. Please choose how you want to proceed.
To save the changes you made from the Bryntum Calendar, select the Copy over option, and the newer version with your changes will open in a new window.
When working with draft content, you need to press the Save button in the Storyblok Visual Editor after making changes to the calendar. This is because calendar modifications create a new draft version that requires manual resolution of the content conflict.
Next steps
This tutorial gives you a starting point for using Bryntum Calendar with Storyblok. The Bryntum Calendar persists events, including recurring events, and resources.
With Storyblok and Bryntum Calendar working together, you’re ready to deliver modern, collaborative scheduling experiences for any use case. Keep experimenting, share your feedback, and let this integration be the launchpad for even more ambitious projects!
Take a look at the Bryntum Calendar examples page to see additional features you can add, such as: