Merge branch 'feature/graphql-interfaces' of https://github.com/ochicf/Vulcan into ochicf-feature/graphql-interfaces

# Conflicts:
#	.meteor/packages
This commit is contained in:
SachaG 2017-07-29 15:11:23 +09:00
commit 12925718a5
23 changed files with 618 additions and 3 deletions

View file

@ -20,4 +20,5 @@ example-simple
# example-forum
# example-customization
# example-permissions
# example-membership
# example-membership
# example-interfaces

View file

@ -0,0 +1,3 @@
import Categories from '../modules/index.js';
export default Categories;

View file

@ -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;

View file

@ -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;

View file

@ -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));

View file

@ -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);

View file

@ -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);

View 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;

View file

@ -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;

View file

@ -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
}
}
}
`);

View file

@ -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);

View 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;

View 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 },
}
};
});

View 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';

View 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,
},
});

View 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 });

View file

@ -0,0 +1,2 @@
import '../modules/index.js';
import './seed.js';

View 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
});
});
}
});

File diff suppressed because one or more lines are too long

View 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;
}

View 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');
});

View file

@ -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

View file

@ -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 ')}
}
`