diff --git a/packages/core/src/components/index.tsx b/packages/core/src/components/index.tsx new file mode 100644 index 00000000..1cbb6130 --- /dev/null +++ b/packages/core/src/components/index.tsx @@ -0,0 +1 @@ +export * from './live'; diff --git a/packages/core/src/components/live/Data.tsx b/packages/core/src/components/live/Data.tsx new file mode 100644 index 00000000..9cdb9eee --- /dev/null +++ b/packages/core/src/components/live/Data.tsx @@ -0,0 +1,19 @@ +import React from 'react'; + +import { useData } from '@staticcms/core/lib'; + +import type { ValueOrNestedValue } from '@staticcms/core/interface'; +import type { FC } from 'react'; + +export interface DataProps { + path: string; + value: ValueOrNestedValue; +} + +const Data: FC = ({ path, value }) => { + const data = useData(value, path); + + return <>{data}; +}; + +export default Data; diff --git a/packages/core/src/components/live/index.tsx b/packages/core/src/components/live/index.tsx new file mode 100644 index 00000000..ee182e9e --- /dev/null +++ b/packages/core/src/components/live/index.tsx @@ -0,0 +1 @@ +export { default as Data, type DataProps } from './Data'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 082d698a..e5bbb31f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'; import { currentBackend } from './backend'; import bootstrap from './bootstrap'; +import useData from './lib/hooks/useData'; import useEntries from './lib/hooks/useEntries'; import useFolderSupport from './lib/hooks/useFolderSupport'; import useHasChildErrors from './lib/hooks/useHasChildErrors'; @@ -15,6 +16,7 @@ import Registry from './lib/registry'; export * from './backends'; export * from './interface'; +export * from './components'; export * from './lib'; export { default as locales } from './locales'; export * from './widgets'; @@ -41,6 +43,7 @@ if (typeof window !== 'undefined') { window.useMediaFiles = window.useMediaFiles || useMediaFiles; window.useMediaInsert = window.useMediaInsert || useMediaInsert; window.useUUID = window.useUUID || useUUID; + window.useData = window.useData || useData; window.useNavigate = window.useNavigate || useNavigate; } diff --git a/packages/core/src/lib/hooks/index.ts b/packages/core/src/lib/hooks/index.ts index dc57e522..c0f3abc1 100644 --- a/packages/core/src/lib/hooks/index.ts +++ b/packages/core/src/lib/hooks/index.ts @@ -1,8 +1,9 @@ +export { default as useData } from './useData'; export { default as useEntries } from './useEntries'; export { default as useFolderSupport } from './useFolderSupport'; export { default as useHasChildErrors } from './useHasChildErrors'; export { default as useIsMediaAsset } from './useIsMediaAsset'; -export { default as useMediaAsset, useGetMediaAsset } from './useMediaAsset'; +export { useGetMediaAsset, default as useMediaAsset } from './useMediaAsset'; export { default as useMediaFiles } from './useMediaFiles'; export { default as useMediaInsert } from './useMediaInsert'; export { default as useMediaPersist } from './useMediaPersist'; diff --git a/packages/core/src/lib/hooks/useData.tsx b/packages/core/src/lib/hooks/useData.tsx new file mode 100644 index 00000000..bf1d163b --- /dev/null +++ b/packages/core/src/lib/hooks/useData.tsx @@ -0,0 +1,39 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; + +import type { ValueOrNestedValue } from '@staticcms/core/interface'; +import type DataUpdateEvent from '../util/events/DataEvent'; + +export default function useData(value: ValueOrNestedValue, path: string) { + const [data, setData] = useState(value); + + const [searchParams] = useSearchParams(); + const isCms = searchParams.get('useCmsData') === 'true'; + + const onDataChange = useCallback( + (event: DataUpdateEvent) => { + if (!isCms) { + return; + } + + if (event.detail.fieldPath === path) { + setData(event.detail.value); + } + }, + [isCms, path], + ) as EventListenerOrEventListenerObject; + + useEffect(() => { + if (!isCms) { + return; + } + + window.addEventListener('data:update', onDataChange); + + return () => { + window.removeEventListener('data:update', onDataChange); + }; + }, [isCms, onDataChange]); + + return data ?? null; +} diff --git a/packages/core/src/lib/hooks/useEntryCallback.ts b/packages/core/src/lib/hooks/useEntryCallback.ts index 19cfb788..97ae4feb 100644 --- a/packages/core/src/lib/hooks/useEntryCallback.ts +++ b/packages/core/src/lib/hooks/useEntryCallback.ts @@ -9,6 +9,7 @@ import { useAppDispatch, useAppSelector } from '@staticcms/core/store/hooks'; import { invokeEvent } from '../registry'; import { fileForEntry } from '../util/collection.util'; import useDebouncedCallback from './useDebouncedCallback'; +import DataUpdateEvent from '../util/events/DataEvent'; import type { Collection, EntryData, Field } from '@staticcms/core/interface'; @@ -25,7 +26,25 @@ async function handleChange( let newEntry = cloneDeep(entry); if (!isEqual(oldValue, newValue)) { - newEntry = await invokeEvent({ name: 'change', collection, field: field.name, data: newEntry }); + const fieldPath = path.join('.'); + + newEntry = await invokeEvent({ + name: 'change', + collection, + field: field.name, + fieldPath, + data: newEntry, + }); + + const updatedValue = get(newEntry, path); + + window.dispatchEvent( + new DataUpdateEvent({ + field: field.name, + fieldPath, + value: updatedValue, + }), + ); } if ('fields' in field && field.fields) { diff --git a/packages/core/src/lib/registry.ts b/packages/core/src/lib/registry.ts index 73ed45bb..a48ea94d 100644 --- a/packages/core/src/lib/registry.ts +++ b/packages/core/src/lib/registry.ts @@ -507,6 +507,7 @@ export async function invokeEvent(event: { collection: string; file?: string; field: string; + fieldPath: string; }): Promise; export async function invokeEvent(event: { name: AllowedEvent; diff --git a/packages/core/src/lib/util/events/DataEvent.ts b/packages/core/src/lib/util/events/DataEvent.ts new file mode 100644 index 00000000..4f74a2e6 --- /dev/null +++ b/packages/core/src/lib/util/events/DataEvent.ts @@ -0,0 +1,13 @@ +import type { ValueOrNestedValue } from '@staticcms/core/interface'; + +export interface DataUpdateEventProps { + field: string; + fieldPath: string; + value: ValueOrNestedValue; +} + +export default class DataUpdateEvent extends CustomEvent { + constructor(detail: DataUpdateEventProps) { + super('data:update', { detail }); + } +} diff --git a/packages/core/src/lib/util/window.util.ts b/packages/core/src/lib/util/window.util.ts index c66b4964..13573cb5 100644 --- a/packages/core/src/lib/util/window.util.ts +++ b/packages/core/src/lib/util/window.util.ts @@ -2,12 +2,14 @@ import { useEffect } from 'react'; import type AlertEvent from './events/AlertEvent'; import type ConfirmEvent from './events/ConfirmEvent'; +import type DataUpdateEvent from './events/DataEvent'; import type MediaLibraryCloseEvent from './events/MediaLibraryCloseEvent'; interface EventMap { alert: AlertEvent; confirm: ConfirmEvent; mediaLibraryClose: MediaLibraryCloseEvent; + 'data:update': DataUpdateEvent; } export function useWindowEvent( diff --git a/packages/core/src/types/global.d.ts b/packages/core/src/types/global.d.ts index 168b7871..f3c36882 100644 --- a/packages/core/src/types/global.d.ts +++ b/packages/core/src/types/global.d.ts @@ -13,6 +13,7 @@ import type { useMediaFiles, useMediaInsert, useUUID, + useData, } from '../lib/hooks'; declare global { @@ -34,6 +35,7 @@ declare global { useMediaFiles: useMediaFiles; useMediaInsert: useMediaInsert; useUUID: useUUID; + useData: useData; useNavigate: useNavigate; } }