Merge pull request #2 from foomo/feat/enable-multiple-rootTrees

feat: enable multiple trees
This commit is contained in:
Tomaz Jejcic 2023-02-21 11:27:57 +01:00 committed by GitHub
commit bdc7b921ce
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 343 additions and 279 deletions

View File

@ -17,38 +17,32 @@
"singleQuote": true
},
"devDependencies": {
"@types/react": "^18.0.20",
"@types/styled-components": "^5.1.26",
"@typescript-eslint/eslint-plugin": "^5.0.0",
"@typescript-eslint/parser": "^5.38.0",
"contentful-management": "^10.6.3",
"contentful-ui-extensions-sdk": "^4.8.1",
"eslint": "^8.0.1",
"eslint-config-prettier": "^8.5.0",
"eslint-config-standard-with-typescript": "^23.0.0",
"eslint-plugin-import": "^2.25.2",
"eslint-plugin-json": "3.1.0",
"eslint-plugin-n": "^15.0.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-promise": "^6.0.0",
"eslint-plugin-react": "^7.31.8",
"immer": "^9.0.15",
"install-peers": "^1.0.4",
"prettier": "2.7.1",
"react": "18.2.0",
"styled-components": "^5.3.5",
"typescript": "*",
"use-immer": "^0.7.0"
},
"peerDependencies": {
"install-peers": "^1.0.4"
},
"dependencies": {
"contentful-management": "^10.15.0",
"contentful-ui-extensions-sdk": "^4.12.1",
"immer": "^9.0.15",
"react": "^18.2.0",
"styled-components": "^5.3.5",
"use-immer": "^0.7.0"
}
"@types/react": "^18.0.20",
"@types/styled-components": "^5.1.26",
"@typescript-eslint/eslint-plugin": "^5.0.0",
"@typescript-eslint/parser": "^5.38.0",
"eslint": "^8.0.1",
"eslint-config-prettier": "^8.5.0",
"eslint-config-standard-with-typescript": "^23.0.0",
"eslint-plugin-import": "^2.25.2",
"eslint-plugin-json": "3.1.0",
"eslint-plugin-n": "^15.0.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-promise": "^6.0.0",
"eslint-plugin-react": "^7.31.8",
"install-peers": "^1.0.4",
"prettier": "2.7.1",
"typescript": "*"
},
"peerDependencies": {
"install-peers": "^1.0.4"
},
"dependencies": {
"contentful-management": "10.15.0",
"contentful-ui-extensions-sdk": "4.12.1",
"immer": "9.0.15",
"react": "18.2.0",
"styled-components": "5.3.5",
"use-immer": "0.7.0"
}
}

View File

@ -6,8 +6,10 @@ export interface UiPalette {
}
export const StyledContentTreeTable = styled.table`
width: 100%;
color: black;
border: 0;
padding: 0 60px;
margin: 0 auto;
td {
padding: 0.2em 1em 0.2em 0.2em;

View File

@ -1,15 +1,9 @@
import {
EntryProps,
KeyValueMap,
Link,
PlainClientAPI,
} from 'contentful-management';
import { PlainClientAPI } from 'contentful-management';
import { PageExtensionSDK } from 'contentful-ui-extensions-sdk';
import React, { ReactElement, useEffect, useState } from 'react';
import { useImmer } from 'use-immer';
import { StyledContentTreeTable } from './ContentTree.styled';
import ContentTreeNode, { ContentTreeNodeProps } from './ContentTreeNode';
import { ContentTreeRoot } from './ContentTreeRoot';
import { emptyNodeProps, cfEntriesToNodes } from './ContentTreeUtils';
export interface ContentTreeProps {
sdkInstance: PageExtensionSDK;
@ -21,250 +15,47 @@ export interface ContentTreeProps {
iconRegistry?: { [index: string]: string };
}
const emptyNodeProps = (): ContentTreeNodeProps => {
return { id: '', name: '', expand: false, parentId: '' };
};
export const ContentTree = (props: ContentTreeProps): ReactElement => {
const [stLocale] = useState(props.locales[0]);
const [stRoot, setStRoot] = useImmer(emptyNodeProps());
// EFFECTS
const [rootNodes, setRootNodes] = useImmer([emptyNodeProps()]);
useEffect(() => {
if (props.sdkInstance) {
loadRootData().catch((err) => {
loadData().catch((err) => {
throw new Error('loadRootData', err);
});
}
}, [props.sdkInstance]);
// FUNCTIONS
const addChildNodes = async (node: ContentTreeNodeProps): Promise<void> => {
let childNodes: ContentTreeNodeProps[] = [];
const cfChildren = await getContentfulChildEntries(node.id);
childNodes = cfEntriesToNodes(cfChildren, node.id);
setStRoot((draft) => {
recursiveProcessNodes(
node.id,
(targetNode) => {
targetNode.childNodes = childNodes;
targetNode.expand = true;
},
draft
);
console.log('🎈draft', draft);
});
};
const recursiveProcessNodes = (
targetNodeId: string,
processNode: (node: ContentTreeNodeProps) => void,
node: ContentTreeNodeProps
): void => {
if (node.id === targetNodeId) {
processNode(node);
}
if (node.childNodes != null) {
for (const targetNode of node.childNodes) {
recursiveProcessNodes(targetNodeId, processNode, targetNode);
}
}
};
const editEntry = async (entryId: string): Promise<void> => {
await props.sdkInstance.navigator.openEntry(entryId, { slideIn: true });
};
const getContentfulChildEntries = async (
parentId: string
): Promise<Array<EntryProps<KeyValueMap>>> => {
const parentItem = await props.cma.entry.get({ entryId: parentId });
const allChildIds: string[] = [];
for (const key of Object.keys(parentItem.fields)) {
if (props.nodeContentTypes.includes(key)) {
const childNodeRefs = parentItem.fields[key][stLocale] as
| Link<string>
| Array<Link<string>>;
if (Array.isArray(childNodeRefs)) {
for (const childNodeRef of childNodeRefs) {
allChildIds.push(childNodeRef.sys.id);
}
} else {
allChildIds.push(childNodeRefs.sys.id);
}
}
}
const allItems: Array<EntryProps<KeyValueMap>> = [];
let done = false;
let skip = 0;
while (!done) {
const col = await props.cma.entry.getMany({
query: {
'sys.id[in]': allChildIds.join(','),
skip,
},
});
allItems.push(...col.items);
if (allItems.length < col.total) {
skip += 100;
} else {
done = true;
}
}
const cfChildren: Array<EntryProps<KeyValueMap>> = [];
const idPositionMap: { [index: string]: number } = allItems.reduce(
(acc: any, el, i) => {
acc[el.sys.id] = i;
return acc;
},
{}
);
for (const childId of allChildIds) {
if (allItems[idPositionMap[childId]]) {
cfChildren.push(allItems[idPositionMap[childId]]);
}
}
return cfChildren;
};
const cfEntriesToNodes = (
entries: Array<EntryProps<KeyValueMap>>,
parentId?: string
): ContentTreeNodeProps[] => {
if (entries.length === 0) {
return [];
}
const nodeArray: ContentTreeNodeProps[] = [];
entries.forEach((entry) => {
if (!entry) {
console.log('this entry is nil');
return;
}
let name = '';
for (const titleField of props.titleFields) {
if (entry.fields[titleField]?.[stLocale]) {
name = entry.fields[titleField][stLocale];
break;
}
}
if (name === '') {
name = entry.sys.id;
}
const node: ContentTreeNodeProps = {
id: entry.sys.id,
name,
contentType: entry.sys.contentType.sys.id,
icon:
props.iconRegistry != null
? props.iconRegistry[entry.sys.contentType.sys.id]
: '',
expand: !!parentId,
parentId,
hasChildNodes: cfEntryHasChildren(entry),
publishingStatus: cfEntryPublishingStatus(entry),
updatedAt: entry.sys.updatedAt,
publishedAt: entry.sys.publishedAt,
};
nodeArray.push(node);
});
return nodeArray;
};
const cfEntryHasChildren = (entry: EntryProps<KeyValueMap>): boolean => {
for (const nodeContentType of props.nodeContentTypes) {
for (const locale of props.locales) {
if (entry.fields[nodeContentType]?.[locale]) {
return true;
}
}
}
return false;
};
const cfEntryPublishingStatus = (entry: EntryProps<KeyValueMap>): string => {
if (!entry.sys.publishedVersion) {
return 'draft';
}
if (entry.sys.version - entry.sys.publishedVersion === 1) {
return 'published';
}
return 'changed';
};
const loadRootData = async (): Promise<void> => {
const loadData = async (): Promise<void> => {
const CfRootData = await props.cma.entry.getMany({
query: { content_type: props.rootType },
});
const rootNodes = cfEntriesToNodes(CfRootData.items);
for (const rootNode of rootNodes) {
const childEntries = await getContentfulChildEntries(rootNode.id);
const childNodes = cfEntriesToNodes(childEntries, rootNode.id);
const nodes = [rootNode, ...childNodes];
if (nodes.length > 0) {
const newIdPositionMap = nodes.reduce((acc: any, el, i) => {
acc[el.id] = i;
return acc;
}, {});
let tree: ContentTreeNodeProps = emptyNodeProps();
nodes.forEach((node: ContentTreeNodeProps) => {
node.childNodes = [];
if (!node.parentId) {
tree = node;
return;
}
const parentEl = nodes[newIdPositionMap[node.parentId]];
if (parentEl) {
parentEl.childNodes = [...(parentEl.childNodes ?? []), node];
parentEl.expand = true;
}
});
console.log('🌴 tree', tree);
setStRoot(tree);
}
}
const nodes = cfEntriesToNodes(
CfRootData.items,
props.titleFields,
stLocale,
props.locales,
props.nodeContentTypes,
props.iconRegistry
);
setRootNodes(nodes);
};
const removeChildNodes = (node: ContentTreeNodeProps): void => {
setStRoot((draft) => {
recursiveProcessNodes(
node.id,
(targetNode) => {
targetNode.childNodes = [];
targetNode.expand = false;
},
draft
);
console.log('🎈draft', draft);
});
};
console.log(
'=============================== RENDER ====================================',
stRoot
);
// create ID mapping
return (
<>
<StyledContentTreeTable>
<tbody>
<tr>
<th>Nodes</th>
<th>Content Type</th>
<th>Status</th>
<th>Last Modified</th>
<th>Last Published</th>
</tr>
<ContentTreeNode
node={stRoot}
depth={0}
addChildNodes={addChildNodes}
removeChildNodes={removeChildNodes}
editEntry={editEntry}
/>
</tbody>
</StyledContentTreeTable>
{rootNodes.map((node, i) => (
<ContentTreeRoot
key={i.toString()}
node={node}
locales={props.locales}
nodeContentTypes={props.nodeContentTypes}
iconRegistry={props.iconRegistry}
cma={props.cma}
titleFields={props.titleFields}
sdkInstance={props.sdkInstance}
/>
))}
</>
);
};

View File

@ -31,11 +31,8 @@ const ContentTreeNode = (props: {
editEntry: (nodeId: string) => Promise<void>;
}): ReactElement => {
const [loading, setLoading] = useState(false);
const [node] = useState(props.node);
// React.useEffect(()=>{setLoading(false)},[props.node.childNodes])
const addChildren = async (): Promise<void> => {
const addChildren = async (node: ContentTreeNodeProps): Promise<void> => {
setLoading(true);
await props.addChildNodes(node);
setLoading(false);
@ -47,8 +44,8 @@ const ContentTreeNode = (props: {
});
};
const handleAddChildren = (): void => {
addChildren().catch((err) => {
const handleAddChildren = (node: ContentTreeNodeProps): void => {
addChildren(node).catch((err) => {
throw new Error('handleAddChildren', err);
});
};
@ -62,9 +59,9 @@ const ContentTreeNode = (props: {
<StyledSpinner>-</StyledSpinner>
) : props.node.hasChildNodes ? (
props.node.expand ? (
<a onClick={() => props.removeChildNodes(props.node)}>-</a>
<a onClick={() => handleAddChildren(props.node)}>+</a>
) : (
<a onClick={() => handleAddChildren()}>+</a>
<a onClick={() => props.removeChildNodes(props.node)}>-</a>
)
) : null}
</StyledContentTreeNodeWedge>
@ -89,7 +86,7 @@ const ContentTreeNode = (props: {
{props.node.childNodes?.map((node, i) => {
return (
<ContentTreeNode
key={i}
key={i.toString()}
node={node}
depth={props.depth && props.depth + 1}
addChildNodes={props.addChildNodes}

204
src/ContentTreeRoot.tsx Normal file
View File

@ -0,0 +1,204 @@
import {
EntryProps,
KeyValueMap,
Link,
PlainClientAPI,
} from 'contentful-management';
import { PageExtensionSDK } from 'contentful-ui-extensions-sdk';
import React, { ReactElement, useEffect, useState } from 'react';
import { useImmer } from 'use-immer';
import { StyledContentTreeTable } from './ContentTree.styled';
import ContentTreeNode, { ContentTreeNodeProps } from './ContentTreeNode';
import { emptyNodeProps, cfEntriesToNodes } from './ContentTreeUtils';
export interface ContentTreeRootProps {
node: ContentTreeNodeProps;
sdkInstance: PageExtensionSDK;
cma: PlainClientAPI;
nodeContentTypes: string[];
titleFields: string[];
locales: string[]; // the first is the default locale
iconRegistry?: { [index: string]: string };
}
export const ContentTreeRoot = (props: ContentTreeRootProps): ReactElement => {
const [stLocale] = useState(props.locales[0]);
const [stRoot, setStRoot] = useImmer(emptyNodeProps());
useEffect(() => {
if (props.node.id) {
loadRootData(props.node).catch((err) => {
throw new Error('loadRootData', err);
});
}
}, [props.node]);
const loadRootData = async (
rootNode: ContentTreeNodeProps
): Promise<void> => {
const childEntries = await getContentfulChildEntries(rootNode.id);
const childNodes = cfEntriesToNodes(
childEntries,
props.titleFields,
stLocale,
props.locales,
props.nodeContentTypes,
props.iconRegistry,
rootNode.id
);
const nodes = [rootNode, ...childNodes];
if (nodes.length > 0) {
const newIdPositionMap = nodes.reduce((acc: any, el, i) => {
acc[el.id] = i;
return acc;
}, {});
let tree: ContentTreeNodeProps = emptyNodeProps();
nodes.forEach((node: ContentTreeNodeProps) => {
node.childNodes = [];
if (!node.parentId) {
tree = node;
return;
}
const parentEl = nodes[newIdPositionMap[node.parentId]];
if (parentEl) {
parentEl.childNodes = [...(parentEl.childNodes ?? []), node];
parentEl.expand = false;
}
});
console.log('🌴 tree', tree);
setStRoot(tree);
}
};
const addChildNodes = async (node: ContentTreeNodeProps): Promise<void> => {
let childNodes: ContentTreeNodeProps[] = [];
const cfChildren = await getContentfulChildEntries(node.id);
childNodes = cfEntriesToNodes(
cfChildren,
props.titleFields,
stLocale,
props.locales,
props.nodeContentTypes,
props.iconRegistry,
node.id
);
setStRoot((draft) => {
recursiveProcessNodes(
node.id,
(targetNode) => {
targetNode.childNodes = childNodes;
targetNode.expand = false;
},
draft
);
});
};
const recursiveProcessNodes = (
targetNodeId: string,
processNode: (node: ContentTreeNodeProps) => void,
node: ContentTreeNodeProps
): void => {
if (node.id === targetNodeId) {
processNode(node);
}
if (node.childNodes != null) {
for (const targetNode of node.childNodes) {
recursiveProcessNodes(targetNodeId, processNode, targetNode);
}
}
};
const editEntry = async (entryId: string): Promise<void> => {
await props.sdkInstance.navigator.openEntry(entryId, { slideIn: true });
};
const getContentfulChildEntries = async (
parentId: string
): Promise<Array<EntryProps<KeyValueMap>>> => {
const parentItem = await props.cma.entry.get({ entryId: parentId });
const allChildIds: string[] = [];
for (const key of Object.keys(parentItem.fields)) {
if (props.nodeContentTypes.includes(key)) {
const childNodeRefs = parentItem.fields[key][stLocale] as
| Link<string>
| Array<Link<string>>;
if (Array.isArray(childNodeRefs)) {
for (const childNodeRef of childNodeRefs) {
allChildIds.push(childNodeRef.sys.id);
}
} else {
allChildIds.push(childNodeRefs.sys.id);
}
}
}
const allItems: Array<EntryProps<KeyValueMap>> = [];
let done = false;
let skip = 0;
while (!done) {
const col = await props.cma.entry.getMany({
query: {
'sys.id[in]': allChildIds.join(','),
skip,
},
});
allItems.push(...col.items);
if (allItems.length < col.total) {
skip += 100;
} else {
done = true;
}
}
const cfChildren: Array<EntryProps<KeyValueMap>> = [];
const idPositionMap: { [index: string]: number } = allItems.reduce(
(acc: any, el, i) => {
acc[el.sys.id] = i;
return acc;
},
{}
);
for (const childId of allChildIds) {
if (allItems[idPositionMap[childId]]) {
cfChildren.push(allItems[idPositionMap[childId]]);
}
}
return cfChildren;
};
const removeChildNodes = (node: ContentTreeNodeProps): void => {
setStRoot((draft) => {
recursiveProcessNodes(
node.id,
(targetNode) => {
targetNode.childNodes = [];
targetNode.expand = true;
},
draft
);
});
};
return (
<>
<StyledContentTreeTable>
<tbody>
<tr>
<th>Nodes</th>
<th>Content Type</th>
<th>Status</th>
<th>Last Modified</th>
<th>Last Published</th>
</tr>
<ContentTreeNode
node={stRoot}
depth={1}
addChildNodes={addChildNodes}
removeChildNodes={removeChildNodes}
editEntry={editEntry}
/>
</tbody>
</StyledContentTreeTable>
</>
);
};

76
src/ContentTreeUtils.tsx Normal file
View File

@ -0,0 +1,76 @@
import { EntryProps, KeyValueMap } from 'contentful-management';
import { ContentTreeNodeProps } from './ContentTreeNode';
export const emptyNodeProps = (): ContentTreeNodeProps => {
return { id: '', name: '', expand: false, parentId: '' };
};
const cfEntryHasChildren = (
entry: EntryProps<KeyValueMap>,
nodeContentTypes: string[],
locales: string[]
): boolean => {
for (const nodeContentType of nodeContentTypes) {
for (const locale of locales) {
if (entry.fields[nodeContentType]?.[locale]) {
return true;
}
}
}
return false;
};
const cfEntryPublishingStatus = (entry: EntryProps<KeyValueMap>): string => {
if (!entry.sys.publishedVersion) {
return 'draft';
}
if (entry.sys.version - entry.sys.publishedVersion === 1) {
return 'published';
}
return 'changed';
};
export const cfEntriesToNodes = (
entries: Array<EntryProps<KeyValueMap>>,
titleFields: string[],
stLocale: string,
locales: string[],
nodeContentTypes: string[],
iconRegistry?: { [index: string]: string },
parentId?: string
): ContentTreeNodeProps[] => {
if (entries.length === 0) {
return [];
}
const nodeArray: ContentTreeNodeProps[] = [];
entries.forEach((entry) => {
if (!entry) {
return;
}
let name = '';
for (const titleField of titleFields) {
if (entry.fields[titleField]?.[stLocale]) {
name = entry.fields[titleField][stLocale];
break;
}
}
if (name === '') {
name = entry.sys.id;
}
const node: ContentTreeNodeProps = {
id: entry.sys.id,
name,
contentType: entry.sys.contentType.sys.id,
icon:
iconRegistry != null ? iconRegistry[entry.sys.contentType.sys.id] : '',
expand: !!parentId,
parentId,
hasChildNodes: cfEntryHasChildren(entry, nodeContentTypes, locales),
publishingStatus: cfEntryPublishingStatus(entry),
updatedAt: entry.sys.updatedAt,
publishedAt: entry.sys.publishedAt,
};
nodeArray.push(node);
});
return nodeArray;
};