mirror of
https://github.com/vale981/Vulcan
synced 2025-03-05 09:31:43 -05:00
Merge branch 'feature/graphql-interfaces' of https://github.com/ochicf/Vulcan into ochicf-feature/graphql-interfaces
# Conflicts: # .meteor/packages
This commit is contained in:
commit
12925718a5
23 changed files with 618 additions and 3 deletions
|
@ -20,4 +20,5 @@ example-simple
|
|||
# example-forum
|
||||
# example-customization
|
||||
# example-permissions
|
||||
# example-membership
|
||||
# example-membership
|
||||
# example-interfaces
|
||||
|
|
3
packages/example-interfaces/lib/client/main.js
Normal file
3
packages/example-interfaces/lib/client/main.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
import Categories from '../modules/index.js';
|
||||
|
||||
export default Categories;
|
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
|
||||
A component to configure the "edit category" form.
|
||||
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Components, registerComponent, getFragment } from "meteor/vulcan:core";
|
||||
|
||||
import Categories from '../../modules/categories/collection.js';
|
||||
|
||||
const CategoriesEditForm = ({documentId, closeModal}) =>
|
||||
|
||||
<Components.SmartForm
|
||||
collection={Categories}
|
||||
documentId={documentId}
|
||||
mutationFragment={getFragment('CategoriesItemFragment')}
|
||||
showRemove={false} // not properly implemented in the example package
|
||||
successCallback={document => {
|
||||
closeModal();
|
||||
}}
|
||||
/>
|
||||
|
||||
export default CategoriesEditForm;
|
|
@ -0,0 +1,52 @@
|
|||
/*
|
||||
|
||||
An item in the categories list.
|
||||
Wrapped with the "withCurrentUser" container.
|
||||
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Components, registerComponent } from 'meteor/vulcan:core';
|
||||
|
||||
import Categories from '../../modules/categories/collection.js';
|
||||
import CategoriesEditForm from './CategoriesEditForm.jsx';
|
||||
import CategoriesNewForm from './CategoriesNewForm.jsx';
|
||||
import CategoriesList from './CategoriesList.jsx';
|
||||
|
||||
const CategoriesItem = ({category, currentUser}) =>
|
||||
|
||||
<div className={`category ${category.parentId ? "child-category" : "top-category"}`} >
|
||||
|
||||
<div className="category-header">
|
||||
|
||||
{/* document properties */}
|
||||
{category.parentId
|
||||
? <h5>{category.name}</h5>
|
||||
: <h4>{category.name}</h4>
|
||||
}
|
||||
|
||||
{/* edit document form */}
|
||||
|
||||
{Categories.options.mutations.edit.check(currentUser, category) ?
|
||||
<Components.ModalTrigger label="Edit" >
|
||||
<CategoriesEditForm currentUser={currentUser} documentId={category._id} />
|
||||
</Components.ModalTrigger>
|
||||
: null
|
||||
}
|
||||
|
||||
{/* edit document form */}
|
||||
|
||||
{Categories.options.mutations.new.check(currentUser) ?
|
||||
<Components.ModalTrigger label={`New child`}>
|
||||
<CategoriesNewForm parentId={category._id}/>
|
||||
</Components.ModalTrigger>
|
||||
: null
|
||||
}
|
||||
|
||||
</div>
|
||||
|
||||
<CategoriesList terms={{ parentId: category._id, view: 'childrenCategories' }} />
|
||||
|
||||
</div>
|
||||
|
||||
export default CategoriesItem;
|
|
@ -0,0 +1,46 @@
|
|||
/*
|
||||
|
||||
List of categories.
|
||||
Wrapped with the "withList" and "withCurrentUser" containers.
|
||||
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Components, withList, withCurrentUser, Loading } from 'meteor/vulcan:core';
|
||||
|
||||
import Categories from '../../modules/categories/collection.js';
|
||||
import CategoriesItem from './CategoriesItem.jsx';
|
||||
|
||||
const CategoriesList = ({results = [], terms: { parentId }, currentUser, loading, loadMore, count, totalCount}) =>
|
||||
|
||||
<div className="category-list" >
|
||||
|
||||
{loading ?
|
||||
|
||||
<Loading /> :
|
||||
|
||||
<div className="categories">
|
||||
|
||||
{/* documents list */}
|
||||
|
||||
{results.map(category => <CategoriesItem key={category._id} category={category} currentUser={currentUser} />)}
|
||||
|
||||
{/* load more */}
|
||||
|
||||
{totalCount > results.length
|
||||
? <a href="#" onClick={e => {e.preventDefault(); loadMore();}}>Load More ({count}/{totalCount})</a>
|
||||
: null
|
||||
}
|
||||
|
||||
</div>
|
||||
}
|
||||
|
||||
</div>
|
||||
|
||||
const options = {
|
||||
collection: Categories,
|
||||
fragmentName: 'CategoriesItemFragment',
|
||||
limit: 0,
|
||||
};
|
||||
|
||||
export default withList(options)(withCurrentUser(CategoriesList));
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
|
||||
A component to configure the "new category" form.
|
||||
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Components, registerComponent, withCurrentUser, getFragment } from 'meteor/vulcan:core';
|
||||
|
||||
import Categories from '../../modules/categories/collection.js';
|
||||
|
||||
const CategoriesNewForm = ({currentUser, closeModal, parentId}) =>
|
||||
|
||||
<div>
|
||||
|
||||
{Categories.options.mutations.new.check(currentUser) ?
|
||||
<div style={{marginBottom: '20px', paddingBottom: '20px', borderBottom: '1px solid #ccc'}}>
|
||||
<h4>Insert New Document</h4>
|
||||
<Components.SmartForm
|
||||
collection={Categories}
|
||||
mutationFragment={getFragment('CategoriesItemFragment')}
|
||||
successCallback={document => {
|
||||
closeModal();
|
||||
}}
|
||||
prefilledProps={{
|
||||
parentId,
|
||||
}}
|
||||
/>
|
||||
</div> :
|
||||
null
|
||||
}
|
||||
|
||||
</div>
|
||||
|
||||
export default withCurrentUser(CategoriesNewForm);
|
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
|
||||
Main page.
|
||||
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Components, withCurrentUser } from 'meteor/vulcan:core';
|
||||
|
||||
import Categories from '../../modules/categories/collection.js';
|
||||
import CategoriesList from './CategoriesList';
|
||||
import CategoriesNewForm from './CategoriesNewForm.jsx';
|
||||
|
||||
const CategoriesPage = ({results = [], currentUser, loading, loadMore, count, totalCount}) =>
|
||||
|
||||
<div style={{maxWidth: '500px', margin: '20px auto'}}>
|
||||
|
||||
<CategoriesList terms={{ view: 'topLevelCategories' }} />
|
||||
|
||||
{/* new document form */}
|
||||
|
||||
{Categories.options.mutations.new.check(currentUser) ?
|
||||
<Components.ModalTrigger label="New Category">
|
||||
<CategoriesNewForm />
|
||||
</Components.ModalTrigger>
|
||||
: null
|
||||
}
|
||||
|
||||
</div>
|
||||
|
||||
export default withCurrentUser(CategoriesPage);
|
63
packages/example-interfaces/lib/components/common/Layout.jsx
Normal file
63
packages/example-interfaces/lib/components/common/Layout.jsx
Normal file
|
@ -0,0 +1,63 @@
|
|||
/*
|
||||
|
||||
The Layout component.
|
||||
|
||||
In other words, the template used to display every page in the app.
|
||||
Specific pages will be displayed in place of the "children" property.
|
||||
|
||||
Note: the Helmet library is used to insert meta tags and link tags in the <head>
|
||||
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import Helmet from 'react-helmet';
|
||||
import { Components } from 'meteor/vulcan:core';
|
||||
|
||||
const links = [
|
||||
// note: modal popups won't work with anything above alpha.5.
|
||||
// see https://github.com/twbs/bootstrap/issues/21876#issuecomment-276181539
|
||||
{
|
||||
rel: 'stylesheet',
|
||||
type: 'text/css',
|
||||
href: 'https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-alpha.5/css/bootstrap.min.css'
|
||||
},
|
||||
{
|
||||
rel: 'stylesheet',
|
||||
type: 'text/css',
|
||||
href: 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css'
|
||||
}
|
||||
];
|
||||
|
||||
const Layout = ({children}) =>
|
||||
|
||||
<div className="wrapper" id="wrapper">
|
||||
|
||||
<Helmet title="Vulcanstagram" link={links} />
|
||||
|
||||
<link />
|
||||
|
||||
<div style={{maxWidth: '500px', margin: '20px auto'}}>
|
||||
|
||||
{/* user accounts */}
|
||||
|
||||
<div
|
||||
className="user-account"
|
||||
style={{padding: '20px 0', marginBottom: '20px', borderBottom: '1px solid #ccc'}}
|
||||
>
|
||||
|
||||
<Components.AccountsLoginForm />
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div className="main">
|
||||
|
||||
{children}
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
export default Layout;
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
|
||||
The main Categories collection definition file.
|
||||
|
||||
*/
|
||||
|
||||
import { createCollection, getDefaultResolvers, getDefaultMutations } from 'meteor/vulcan:core';
|
||||
import schema from './schema.js';
|
||||
import './fragments.js';
|
||||
import './permissions.js';
|
||||
|
||||
const Categories = createCollection({
|
||||
|
||||
collectionName: 'Categories',
|
||||
|
||||
typeName: 'Category',
|
||||
|
||||
schema,
|
||||
|
||||
resolvers: getDefaultResolvers('Categories'),
|
||||
|
||||
mutations: getDefaultMutations('Categories'),
|
||||
|
||||
interfaces: ['HierarchicalInterface'],
|
||||
|
||||
});
|
||||
|
||||
export default Categories;
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
|
||||
Register the GraphQL fragment used to query for data
|
||||
|
||||
*/
|
||||
|
||||
import { registerFragment } from 'meteor/vulcan:core';
|
||||
|
||||
registerFragment(`
|
||||
fragment CategoriesItemFragment on Category {
|
||||
_id
|
||||
createdAt
|
||||
userId
|
||||
user {
|
||||
displayName
|
||||
}
|
||||
name
|
||||
parentId,
|
||||
parent {
|
||||
... on Category {
|
||||
_id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
|
@ -0,0 +1,14 @@
|
|||
import Users from 'meteor/vulcan:users';
|
||||
|
||||
const membersActions = [
|
||||
'categories.new',
|
||||
'categories.edit.own',
|
||||
'categories.remove.own',
|
||||
];
|
||||
Users.groups.members.can(membersActions);
|
||||
|
||||
const adminActions = [
|
||||
'categories.edit.all',
|
||||
'categories.remove.all'
|
||||
];
|
||||
Users.groups.admins.can(adminActions);
|
73
packages/example-interfaces/lib/modules/categories/schema.js
Normal file
73
packages/example-interfaces/lib/modules/categories/schema.js
Normal file
|
@ -0,0 +1,73 @@
|
|||
/*
|
||||
|
||||
A SimpleSchema-compatible JSON schema
|
||||
|
||||
*/
|
||||
|
||||
const schema = {
|
||||
|
||||
// HierarchicalInterface required properties
|
||||
|
||||
parentId: {
|
||||
type: String,
|
||||
optional: true,
|
||||
viewableBy: ['guests'],
|
||||
insertableBy: ['members'],
|
||||
hidden: true,
|
||||
resolveAs: {
|
||||
fieldName: 'parent',
|
||||
type: 'HierarchicalInterface',
|
||||
resolver: (category, args, context) => {
|
||||
if (!category.parentId) return null;
|
||||
return context.Categories.findOne(
|
||||
{ _id: category.parentId },
|
||||
{ fields: context.Users.getViewableFields(context.currentUser, context.Categories) }
|
||||
);
|
||||
},
|
||||
addOriginalField: true,
|
||||
},
|
||||
},
|
||||
|
||||
// default properties
|
||||
|
||||
_id: {
|
||||
type: String,
|
||||
optional: true,
|
||||
viewableBy: ['guests'],
|
||||
},
|
||||
createdAt: {
|
||||
type: Date,
|
||||
optional: true,
|
||||
viewableBy: ['guests'],
|
||||
onInsert: (document, currentUser) => {
|
||||
return new Date();
|
||||
}
|
||||
},
|
||||
userId: {
|
||||
type: String,
|
||||
optional: true,
|
||||
viewableBy: ['guests'],
|
||||
resolveAs: {
|
||||
fieldName: 'user',
|
||||
type: 'User',
|
||||
resolver: (category, args, context) => {
|
||||
return context.Users.findOne({ _id: category.userId }, { fields: context.Users.getViewableFields(context.currentUser, context.Users) });
|
||||
},
|
||||
addOriginalField: true
|
||||
}
|
||||
},
|
||||
|
||||
// custom properties
|
||||
|
||||
name: {
|
||||
label: 'Name',
|
||||
type: String,
|
||||
optional: true,
|
||||
viewableBy: ['guests'],
|
||||
insertableBy: ['members'],
|
||||
editableBy: ['members'],
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
export default schema;
|
36
packages/example-interfaces/lib/modules/categories/views.js
Normal file
36
packages/example-interfaces/lib/modules/categories/views.js
Normal file
|
@ -0,0 +1,36 @@
|
|||
import Categories from './collection.js';
|
||||
|
||||
// will be common to all other view unless specific properties are overwritten
|
||||
Categories.addDefaultView(function (terms) {
|
||||
return {
|
||||
options: {
|
||||
sort: { name: 1 },
|
||||
limit: 1000,
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
Categories.addView("childrenCategories", function (terms) {
|
||||
return {
|
||||
selector: {
|
||||
parentId: terms.parentId,
|
||||
},
|
||||
options: {
|
||||
sort: { name: 1 },
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
Categories.addView("topLevelCategories", function (terms) {
|
||||
return {
|
||||
selector: {
|
||||
$or: [
|
||||
{ parentId: null },
|
||||
{ parentId: { $exists: false } },
|
||||
],
|
||||
},
|
||||
options: {
|
||||
sort: { name: 1 },
|
||||
}
|
||||
};
|
||||
});
|
9
packages/example-interfaces/lib/modules/index.js
Normal file
9
packages/example-interfaces/lib/modules/index.js
Normal file
|
@ -0,0 +1,9 @@
|
|||
// The interfaces
|
||||
import './interfaces.js';
|
||||
|
||||
// The Categories collection
|
||||
import './categories/collection.js';
|
||||
import './categories/views.js';
|
||||
|
||||
// Routes
|
||||
import './routes.js';
|
18
packages/example-interfaces/lib/modules/interfaces.js
Normal file
18
packages/example-interfaces/lib/modules/interfaces.js
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { addGraphQLSchema, addGraphQLResolvers } from 'meteor/vulcan:core';
|
||||
|
||||
addGraphQLSchema(`
|
||||
interface HierarchicalInterface {
|
||||
parentId: String
|
||||
parent: HierarchicalInterface
|
||||
}
|
||||
`);
|
||||
|
||||
// type must be resolved dynamically at execution time. To prevent this resolver from knowing
|
||||
// all the implementing types we return the parentType name. Note that this may not always work
|
||||
const resolveType = (obj, context, info) => info.parentType.name;
|
||||
|
||||
addGraphQLResolvers({
|
||||
HierarchicalInterface: {
|
||||
__resolveType: resolveType,
|
||||
},
|
||||
});
|
8
packages/example-interfaces/lib/modules/routes.js
Normal file
8
packages/example-interfaces/lib/modules/routes.js
Normal file
|
@ -0,0 +1,8 @@
|
|||
import { addRoute, replaceComponent } from 'meteor/vulcan:core';
|
||||
|
||||
import Layout from '../components/common/Layout.jsx';
|
||||
import CategoriesPage from '../components/categories/CategoriesPage.jsx';
|
||||
|
||||
replaceComponent('Layout', Layout);
|
||||
|
||||
addRoute({ name: 'categories', path: '/', component: CategoriesPage });
|
2
packages/example-interfaces/lib/server/main.js
Normal file
2
packages/example-interfaces/lib/server/main.js
Normal file
|
@ -0,0 +1,2 @@
|
|||
import '../modules/index.js';
|
||||
import './seed.js';
|
98
packages/example-interfaces/lib/server/seed.js
Normal file
98
packages/example-interfaces/lib/server/seed.js
Normal file
|
@ -0,0 +1,98 @@
|
|||
/*
|
||||
|
||||
Seed the database with some dummy content.
|
||||
|
||||
*/
|
||||
|
||||
import Categories from '../modules/categories/collection.js';
|
||||
import Users from 'meteor/vulcan:users';
|
||||
import { newMutation } from 'meteor/vulcan:core';
|
||||
|
||||
const seedData = [
|
||||
{
|
||||
_id: '56yknRE2hKQughQRc',
|
||||
name: 'Music',
|
||||
}, {
|
||||
_id: 'aJSgH5o6yGPWZxdeN',
|
||||
name: 'Pop',
|
||||
parentId: '56yknRE2hKQughQRc',
|
||||
},
|
||||
{
|
||||
_id: 'JXWnLuW8BTsk5MQnp',
|
||||
name: 'Rock',
|
||||
parentId: '56yknRE2hKQughQRc',
|
||||
}, {
|
||||
_id: 'rJiv7dPoXncqus6tM',
|
||||
name: 'Movies',
|
||||
}, {
|
||||
_id: 'MTw4maNZo2efd5hzv',
|
||||
name: 'Action',
|
||||
parentId: 'rJiv7dPoXncqus6tM',
|
||||
}, {
|
||||
_id: 'jp3zyDPvcjNQvJGWL',
|
||||
name: 'Comedy',
|
||||
parentId: 'rJiv7dPoXncqus6tM',
|
||||
}, {
|
||||
_id: 'J9qgemFRrDFYCxbBz',
|
||||
name: 'Romantic comedy',
|
||||
parentId: 'jp3zyDPvcjNQvJGWL',
|
||||
}, {
|
||||
_id: '3yFHQML4D6hKSx4fb',
|
||||
name: 'Dry humor',
|
||||
parentId: 'jp3zyDPvcjNQvJGWL',
|
||||
},{
|
||||
_id: 'E2H9cTEBQt6rkg8uw',
|
||||
name: 'Sports',
|
||||
}, {
|
||||
_id: 'd8S86bFm4gqHsC6Q2',
|
||||
name: 'Football',
|
||||
parentId: 'E2H9cTEBQt6rkg8uw',
|
||||
}, {
|
||||
_id: 'wA2cRz2vYi2Ls6zzh',
|
||||
name: 'Rugby',
|
||||
parentId: 'E2H9cTEBQt6rkg8uw',
|
||||
}, {
|
||||
_id: 'dg7yh5GANnT2QJo8a',
|
||||
name: 'Tennis',
|
||||
parentId: 'E2H9cTEBQt6rkg8uw',
|
||||
},
|
||||
];
|
||||
|
||||
const createUser = function (username, email) {
|
||||
const user = {
|
||||
username,
|
||||
email,
|
||||
isDummy: true
|
||||
};
|
||||
newMutation({
|
||||
collection: Users,
|
||||
document: user,
|
||||
validate: false
|
||||
});
|
||||
}
|
||||
|
||||
var createDummyUsers = function () {
|
||||
console.log('// inserting dummy users…');
|
||||
createUser('Bruce', 'dummyuser1@telescopeapp.org');
|
||||
createUser('Arnold', 'dummyuser2@telescopeapp.org');
|
||||
createUser('Julia', 'dummyuser3@telescopeapp.org');
|
||||
};
|
||||
|
||||
Meteor.startup(function () {
|
||||
if (Users.find().fetch().length === 0) {
|
||||
createDummyUsers();
|
||||
}
|
||||
const currentUser = Users.findOne(); // just get the first user available
|
||||
if (Categories.find().fetch().length === 0) {
|
||||
console.log('// creating dummy categories');
|
||||
seedData.forEach(document => {
|
||||
newMutation({
|
||||
action: 'categories.new',
|
||||
collection: Categories,
|
||||
document: document,
|
||||
currentUser: currentUser,
|
||||
validate: false
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
7
packages/example-interfaces/lib/stylesheets/bootstrap.min.css
vendored
Normal file
7
packages/example-interfaces/lib/stylesheets/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
14
packages/example-interfaces/lib/stylesheets/custom.css
Normal file
14
packages/example-interfaces/lib/stylesheets/custom.css
Normal file
|
@ -0,0 +1,14 @@
|
|||
.category.top-category {
|
||||
padding-bottom: 15px;
|
||||
margin-bottom: 15px;
|
||||
border-bottom: 1px solid #ccc;
|
||||
}
|
||||
|
||||
.category-header > * {
|
||||
display: inline-block;
|
||||
padding-right: 15px;
|
||||
}
|
||||
|
||||
.category > .category-list {
|
||||
padding-left: 25px;
|
||||
}
|
24
packages/example-interfaces/package.js
Normal file
24
packages/example-interfaces/package.js
Normal file
|
@ -0,0 +1,24 @@
|
|||
Package.describe({
|
||||
name: 'example-interfaces',
|
||||
});
|
||||
|
||||
Package.onUse(function (api) {
|
||||
|
||||
api.use([
|
||||
|
||||
// vulcan core
|
||||
'vulcan:core',
|
||||
|
||||
// vulcan packages
|
||||
'vulcan:forms',
|
||||
'vulcan:accounts',
|
||||
|
||||
]);
|
||||
|
||||
api.addFiles('lib/stylesheets/bootstrap.min.css');
|
||||
api.addFiles('lib/stylesheets/custom.css');
|
||||
|
||||
api.mainModule('lib/server/main.js', 'server');
|
||||
api.mainModule('lib/client/main.js', 'client');
|
||||
|
||||
});
|
|
@ -14,7 +14,7 @@ export const registerFragment = fragmentText => {
|
|||
const fragmentName = fragmentText.match(/fragment (.*) on/)[1];
|
||||
|
||||
// extract subFragments from text
|
||||
const matchedSubFragments = fragmentText.match(/\.\.\.(.*)/g) || [];
|
||||
const matchedSubFragments = fragmentText.match(/\.\.\.([^\s].*)/g) || [];
|
||||
const subFragments = _.unique(matchedSubFragments.map(f => f.replace('...', '')));
|
||||
|
||||
// register fragment
|
||||
|
|
|
@ -182,8 +182,11 @@ export const GraphQLSchema = {
|
|||
}
|
||||
});
|
||||
|
||||
const { interfaces = [] } = collection.options;
|
||||
const graphQLInterfaces = interfaces.length ? `implements ${interfaces.join(`, `)} ` : '';
|
||||
|
||||
let graphQLSchema = `
|
||||
type ${mainTypeName} {
|
||||
type ${mainTypeName} ${graphQLInterfaces}{
|
||||
${mainSchema.join('\n ')}
|
||||
}
|
||||
`
|
||||
|
|
Loading…
Add table
Reference in a new issue