Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/on-push-app-extensions.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ jobs:
- clear-on-change
- working-group-deliverables
- partial-last-updated
- project-role-validator
steps:
- name: Checkout
uses: actions/checkout@v3
Expand Down
37 changes: 37 additions & 0 deletions .pnp.cjs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
{
"name": "@asap-hub/contentful-app-project-role-validator",
"version": "0.1.0",
"private": true,
"dependencies": {
"@contentful/app-sdk": "4.46.0",
"@contentful/f36-components": "4.56.2",
"@contentful/f36-tokens": "4.0.4",
"@contentful/react-apps-toolkit": "1.2.16",
"contentful-management": "10.46.4",
"emotion": "10.0.27",
"react": "17.0.2",
"react-dom": "17.0.2",
"react-scripts": "5.0.1"
},
"scripts": {
"start": "cross-env BROWSER=none DISABLE_ESLINT_PLUGIN=true react-scripts start",
"build": "DISABLE_ESLINT_PLUGIN=true react-scripts build",
"test": "react-scripts test",
"test:no-watch": "react-scripts test --watchAll=false",
"eject": "react-scripts eject",
"create-app-definition": "contentful-app-scripts create-app-definition",
"upload": "contentful-app-scripts upload --bundle-dir ./build",
"upload-ci": "contentful-app-scripts upload --ci --bundle-dir ./build --organization-id $CONTENTFUL_ORG_ID --definition-id $CONTENTFUL_APP_DEF_ID --token $CONTENTFUL_ACCESS_TOKEN"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@contentful/app-scripts": "1.33.2",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "12.1.5",
"@types/jest": "29.5.14",
"@types/node": "20.10.5",
"@types/react": "17.0.65",
"@types/react-dom": "17.0.20",
"@types/testing-library__jest-dom": "5.14.9",
"cross-env": "7.0.3",
"typescript": "4.9.5"
},
"homepage": "."
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Project Role Validator</title>
</head>
<body>
<div id="root"></div>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import React, { useMemo } from 'react';
import { locations, SidebarExtensionSDK } from '@contentful/app-sdk';
import { useSDK, useAutoResizer } from '@contentful/react-apps-toolkit';
import Sidebar from './locations/Sidebar';

const App = () => {
const sdk = useSDK();

// Call autoResizer here so it collapses iframe when we return null
useAutoResizer();

const Component = useMemo(() => {
if (sdk.location.is(locations.LOCATION_ENTRY_SIDEBAR)) {
// Only render the sidebar for Projects content type
const sidebarSdk = sdk as SidebarExtensionSDK;
const contentTypeId = sidebarSdk.contentType.sys.id;

console.log('Content Type ID:', contentTypeId);

if (contentTypeId === 'projects') {
return Sidebar;
}
}
return null;
}, [sdk]);

return Component ? <Component /> : null;
};

export default App;
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import React from 'react';
import { render } from 'react-dom';

import { GlobalStyles } from '@contentful/f36-components';
import { SDKProvider } from '@contentful/react-apps-toolkit';

import App from './App';

const root = document.getElementById('root');

render(
<SDKProvider>
<GlobalStyles />
<App />
</SDKProvider>,
root,
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import React, { useEffect, useState, useCallback } from 'react';
import { SidebarExtensionSDK } from '@contentful/app-sdk';
import { useSDK } from '@contentful/react-apps-toolkit';
import {
Stack,
Note,
Heading,
List,
ListItem,
} from '@contentful/f36-components';
import { ProjectType, ValidationResult } from '../types';
import { validateMemberRole } from '../validation';

const Sidebar = () => {
const sdk = useSDK<SidebarExtensionSDK>();

const [validationResult, setValidationResult] = useState<ValidationResult>({
isValid: true,
errors: [],
projectType: null,
memberCount: 0,
});
const [isLoading, setIsLoading] = useState(false);

const validateProject = useCallback(async () => {
setIsLoading(true);

try {
const entry = sdk.entry;

// Get project type
const projectType = entry.fields.projectType?.getValue() as
| ProjectType
| undefined;

if (!projectType) {
setValidationResult({
isValid: true,
errors: [],
projectType: null,
memberCount: 0,
});
setIsLoading(false);
return;
}

// Get members
const members = entry.fields.members?.getValue() as
| Array<{ sys: { id: string } }>
| undefined;

if (!members || members.length === 0) {
setValidationResult({
isValid: true,
errors: [],
projectType,
memberCount: 0,
});
setIsLoading(false);
return;
}

// Validate each member
const errors = [];

for (const memberLink of members) {
try {
// Fetch member entry
const memberEntry = await sdk.space.getEntry(memberLink.sys.id);
const memberRole = memberEntry.fields.role?.[sdk.locales.default] as
| string
| undefined;
const memberTitle = memberEntry.fields.title?.[
sdk.locales.default
] as string | undefined;

if (memberRole) {
const error = validateMemberRole(
memberLink.sys.id,
memberTitle,
memberRole,
projectType,
);

if (error) {
errors.push(error);
}
}
} catch (err) {
console.error(`Error fetching member ${memberLink.sys.id}:`, err);
}
}

setValidationResult({
isValid: errors.length === 0,
errors,
projectType,
memberCount: members.length,
});
} catch (error) {
console.error('Validation error:', error);
} finally {
setIsLoading(false);
}
}, [sdk]);

useEffect(() => {
// Initial validation
validateProject();

// Listen for changes to projectType field
const unsubscribeType = sdk.entry.fields.projectType?.onValueChanged(() => {
validateProject();
});

// Listen for changes to members field
const unsubscribeMembers = sdk.entry.fields.members?.onValueChanged(() => {
validateProject();
});

return () => {
unsubscribeType?.();
unsubscribeMembers?.();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sdk]);

const renderStatus = () => {
if (isLoading) {
return (
<Note variant="primary" title="Validating...">
Checking project member roles...
</Note>
);
}

if (!validationResult.projectType) {
return (
<Note variant="warning" title="No Project Type">
Please select a project type to validate member roles.
</Note>
);
}

if (validationResult.memberCount === 0) {
return (
<Note variant="neutral" title="No Members">
No members have been added to this project yet.
</Note>
);
}

if (validationResult.isValid) {
return (
<Note variant="positive" title="Valid roles ✓">
{validationResult.memberCount} member
{validationResult.memberCount !== 1 ? 's have' : ' has'} valid role
{validationResult.memberCount !== 1 ? 's ' : ' '}
for project type: <strong>{validationResult.projectType}</strong>
</Note>
);
}

return (
<Stack flexDirection="column" spacing="spacingS">
<Note variant="negative" title="Invalid Member Roles Found">
<Stack flexDirection="column" spacing="spacingS">
<div>
Found {validationResult.errors.length} invalid role
{validationResult.errors.length !== 1 ? 's' : ''} for project
type: <strong>{validationResult.projectType}</strong>
</div>

<Stack flexDirection="column" spacing="spacingM">
{validationResult.errors.map((error, index: number) => (
<div key={error.memberId || index}>
<Heading as="h4" marginBottom="spacingXs">
{error.memberTitle || `Member ${index + 1}`}
</Heading>
<div style={{ marginBottom: '8px' }}>
<strong>Current role:</strong> {error.currentRole}
</div>
<div>
<strong>
Allowed roles for {validationResult.projectType}:
</strong>
<List style={{ marginTop: '4px', marginLeft: '20px' }}>
{error.allowedRoles.map((role: string) => (
<ListItem key={role}>{role}</ListItem>
))}
</List>
</div>
</div>
))}
</Stack>
</Stack>
</Note>
</Stack>
);
};

return (
<Stack flexDirection="column" spacing="spacingM" padding="spacingM">
{renderStatus()}
</Stack>
);
};

export default Sidebar;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/// <reference types="react-scripts" />
Loading
Loading