i am new in js and i wanted to Enabling Autolinking in Slate.js editor.
for example.
when we type www.google.com in any editor it convert it into the clickable link.
i want that when a enter a link and press space it should be converted into the Clickable link .
here is my current code
all other SHORTCUTS wroks fine but when i try to insert Link it does not show anythings.
the reason is i dont know how to merge link in Childern.
link validation logic is fine .
all i need is when i insert link and press space it should show me a clickable link..
import { Slate, Editable, withReact } from 'slate-react'
import {
Editor,
Transforms,
Range,
Point,
createEditor,
Element as SlateElement,
Descendant,
} from 'slate'
import { withHistory } from 'slate-history'
const SHORTCUTS = {
'*': 'list-item',
'-': 'list-item',
'+': 'list-item',
'>': 'block-quote',
'#': 'heading-one',
'##': 'heading-two',
'###': 'heading-three',
'####': 'heading-four',
'#####': 'heading-five',
'######': 'heading-six',
}
const MarkdownShortcutsExample = () => {
const [value, setValue] = useState<Descendant[]>(initialValue)
const renderElement = useCallback(props => <Element {...props} />, [])
const editor = useMemo(
() => withShortcuts(withReact(withHistory(createEditor()))),
[]
)
return (
<Slate editor={editor} value={value} onChange={value => setValue(value)}>
<Editable
renderElement={renderElement}
placeholder="Write some markdown..."
spellCheck
autoFocus
/>
</Slate>
)
}
const withShortcuts = editor => {
const { deleteBackward, insertText } = editor
editor.insertText = text => {
const { selection } = editor
if (text === ' ' && selection && Range.isCollapsed(selection)) {
const { anchor } = selection
const block = Editor.above(editor, {
match: n => Editor.isBlock(editor, n),
})
const path = block ? block[1] : []
const start = Editor.start(editor, path)
const range = { anchor, focus: start }
const beforeText = Editor.string(editor, range)
console.log("This is testin",validURL(beforeText))
const type = validURL(beforeText)?"link":SHORTCUTS[beforeText]
if (type) {
Transforms.select(editor, range)
Transforms.delete(editor)
const newProperties: Partial<SlateElement> = {
type,
}
Transforms.setNodes(editor, newProperties, {
match: n => Editor.isBlock(editor, n),
})
return
}
}
insertText(text)
}
editor.deleteBackward = (...args) => {
const { selection } = editor
if (selection && Range.isCollapsed(selection)) {
const match = Editor.above(editor, {
match: n => Editor.isBlock(editor, n),
})
if (match) {
const [block, path] = match
const start = Editor.start(editor, path)
if (
!Editor.isEditor(block) &&
SlateElement.isElement(block) &&
block.type !== 'paragraph' &&
Point.equals(selection.anchor, start)
) {
const newProperties: Partial<SlateElement> = {
type: 'paragraph',
}
Transforms.setNodes(editor, newProperties)
if (block.type === 'list-item') {
Transforms.unwrapNodes(editor, {
match: n =>
!Editor.isEditor(n) &&
SlateElement.isElement(n) &&
n.type === 'bulleted-list',
split: true,
})
}
return
}
}
deleteBackward(...args)
}
}
return editor
}
const validURL=(str)=> {
var pattern = new RegExp('^(https?:\\/\\/)?'+ // protocol
'((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|'+ // domain name
'((\\d{1,3}\\.){3}\\d{1,3}))'+ // OR ip (v4) address
'(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*'+ // port and path
'(\\?[;&a-z\\d%_.~+=-]*)?'+ // query string
'(\\#[-a-z\\d_]*)?$','i'); // fragment locator
return !!pattern.test(str);
}
const Element = ({ attributes, children, element }) => {
console.log("777",attributes, children, element)
switch (element.type) {
case 'block-quote':
return <blockquote {...attributes}>{children}</blockquote>
case 'bulleted-list':
return <ul {...attributes}>{children}</ul>
case 'heading-one':
return <h1 {...attributes}>{children}</h1>
case 'heading-two':
return <h2 {...attributes}>{children}</h2>
case 'heading-three':
return <h3 {...attributes}>{children}</h3>
case 'heading-four':
return <h4 {...attributes}>{children}</h4>
case 'heading-five':
return <h5 {...attributes}>{children}</h5>
case 'heading-six':
return <h6 {...attributes}>{children}</h6>
case 'list-item':
return <li {...attributes}>{children}</li>
case 'link':
console.log("Inside")
return <a {...attributes} href={element.url}>
{children}
</a>
default:
return <p {...attributes}>{children}</p>
}
}
const initialValue: Descendant[] = [
{
type: 'link',
url:'',
children:[{ text: '' }],
},
{
type: 'paragraph',
children: [
{
text:
'The editor gives you full control over the logic you can add. For example, it\'s fairly common to want to add markdown-like shortcuts to editors. So that, when you start a line with "> " you get a blockquote that looks like this:',
},
],
},
{
type: 'block-quote',
children: [{ text: 'A wise quote.' }],
},
{
type: 'paragraph',
children: [
{
text:
'Order when you start a line with "## " you get a level-two heading, like this:',
},
],
},
{
type: 'heading-two',
children: [{ text: 'Try it out!' }],
},
{
type: 'paragraph',
children: [
{
text:
'Try it out for yourself! Try starting a new line with ">", "-", or "#"s.',
},
],
},
]
export default MarkdownShortcutsExample````
any help will be appreciated.
thanks in advance
Related
CodeSandbox Example:
https://codesandbox.io/s/slate-2-images-and-links-forked-s09wi
This is basically the withLink() example from the official document.
When you press backspace or cut out to remove the link, the JSON output still contains the link data with empty text. I don't understand why it still remains in the output. Can anyone provide a solution for this?
The withLink example:
const withLinks = editor => {
const { insertData, insertText, isInline } = editor
editor.isInline = element => {
return element.type === 'link' ? true : isInline(element)
}
editor.insertText = text => {
if (text && isUrl(text)) {
wrapLink(editor, text)
} else {
insertText(text)
}
}
editor.insertData = data => {
const text = data.getData('text/plain')
if (text && isUrl(text)) {
wrapLink(editor, text)
} else {
insertData(data)
}
}
return editor
}
const unwrapLink = editor => {
Transforms.unwrapNodes(editor, {
match: n =>
!Editor.isEditor(n) && SlateElement.isElement(n) && n.type === 'link',
})
}
const wrapLink = (editor, url) => {
if (isLinkActive(editor)) {
unwrapLink(editor)
}
const { selection } = editor
const isCollapsed = selection && Range.isCollapsed(selection)
const link: LinkElement = {
type: 'link',
url,
children: isCollapsed ? [{ text: url }] : [],
}
if (isCollapsed) {
Transforms.insertNodes(editor, link)
} else {
Transforms.wrapNodes(editor, link, { split: true })
Transforms.collapse(editor, { edge: 'end' })
}
}
I fixed this issue by adding normalizeNode in withLinks plugin.
const { normalizeNode } = editor
editor.normalizeNode = entry => {
const [node, path] = entry
if (Element.isElement(node) && node.type === 'paragraph') {
const children = Array.from(Node.children(editor, path))
for (const [child, childPath] of children) {
// remove link nodes whose text value is empty string.
// empty text links happen when you move from link to next line or delete link line.
if (Element.isElement(child) && child.type === 'link' && child.children[0].text === '') {
if (children.length === 1) {
Transforms.removeNodes(editor, { at: path })
Transforms.insertNodes(editor,
{
type: 'paragraph',
children: [{ text: '' }],
})
} else {
Transforms.removeNodes(editor, { at: childPath })
}
return
}
}
}
normalizeNode(entry)
}
//a user will call the traverse function like this for example stop the traversal when item found
let foundItem: IGXA.TAnyItem | undefined;
traverse([...], item => {
if (item.ID === "test") {
foundItem = item;
return false
}
})
/**
* Calls the callback for every direct and deep child item in the IGXA item list.
*
*
* #param {TItems} items
* #param {(item: TAnyItem) => void} cb
*/
export const traverse = (
items: TItems,
cb: (item: TAnyItem) => void | false,
) => {
for (const item of items) {
cb(item);
if (
item.Type === EItemType.Folder ||
item.Type === EItemType.MultiArticle
) {
traverse(item.SubItems, cb);
}
}
};
//unit test for the above function items is the array we passed to traverse
describe("traverse", () => {
it("calls the callback for every item", () => {
const items: TItems = [
{
ID: "folder",
Type: EItemType.Folder,
SubItems: [
{
ID: "folder_1",
Type: EItemType.Folder,
SubItems: [
{
ID: "article",
Type: EItemType.ArticleGfx,
Caption: {},
},
],
Caption: {},
},
],
Caption: {},
},
{
ID: "multiarticle",
Type: EItemType.MultiArticle,
Caption: {},
MultiArticleId: "",
SubItems: [
{
ID: "article2",
Type: EItemType.ArticleGfx,
Caption: {},
},
],
},
{
ID: "article3",
Type: EItemType.ArticleGfx,
Caption: {},
},
];
const cb = jest.fn();
traverse(items, cb);
expect(cb).toHaveBeenCalledTimes(6);
expect(cb).toHaveBeenCalledWith(items[items.length - 1]);
});
});
Generators are a perfect fit for this -
function* traverse (t) {
switch (t?.constructor) {
case Array:
for (const v of t)
yield *traverse(v)
break
case Object:
yield t
yield *traverse(t?.SubItems)
break
}
}
Q: "How to return false from traverse function as well as from inner traverse function to stop the traverse?"
A: Generators are pauseable and cancellable so you can easily stop iteration -
function findById (graph, id) {
for (const node in traverse(graph))
if (node.ID === id)
return node // <- return stops iteration and stops the generator
}
console.log(findById(TItems, "folder_1"))
{
ID: "folder_1",
Type: EItemType.Folder,
SubItems: [
{
ID: "article",
Type: EItemType.ArticleGfx,
Caption: {},
},
],
Caption: {},
}
Q: "...and also need unit test for this?"
A: Make a simple graph as the test input -
const mygraph = [
{
id: 1,
SubItems: [
{ id: 2 },
{ id: 3 }
]
},
{ id: 4 },
{
id: 5,
SubItems: [
{
id: 6,
SubItems: [ { id: 7 } ]
},
{ id: 8 }
]
}
]
Then write some simple tests -
it("traverse should visit all of the nodes", _ => {
const ids = Array.from(traverse(mygraph), node => node.id)
assert.strictEqual(ids, [1,2,3,4,5,6,7,8])
})
it("findById should find a node", _ => {
const match = findById(mygraph, 6)
assert.strictEqual(match.id, 6)
assert.strictEqual(match.SubItems.length, 1)
})
it("findById should return undefined for unmatched id", _ => {
const match = findById(mygraph, 99)
assert.strictEqual(match, undefined)
})
Q: "thanks for u detailed answer but i need a callback version too? how to achieve it"
A: rewrite traverse and implement your own yield which passes a resume control to the caller -
function traverse (t, yield, resume = () => void 0) {
switch (t?.constructor) {
case Array:
if (empty(t))
return resume()
else
return traverse(head(t), yield, _ => traverse(tail(t), yield, resume))
case Object:
return yield(t, _ => traverse(t?.SubItems, yield) || resume())
}
}
This depends on a few helpers, empty, head and tail. These aren't necessary but keep the code in traverse a bit cleaner and easier to read -
const head = t => t[0]
const tail = t => t.slice(1)
const empty = t => t.length < 1
Here's findById. Traversal will only continue if resume is called -
function findById (graph, id) {
let found = undefined
traverse(graph, (node, resume) =>
node.id === id ? (found = node) : resume()
)
return found
}
console.log(findById(mygraph, 6))
{ id: 6, SubItems: [ { id: 7 } ] }
Another difference is callback-based traverse cannot plug directly into Array.from. To build an array of all visited nodes, we can push to an array in each callback and unconditionally resume -
function getIds (t) {
const r = []
traverse(t, (node, resume) =>
r.push(node.id) && resume()
)
return r
}
console.log(getIds(mygraph))
[1,2,3,4,5,6,7,8]
For this version, you may wish to implement another unit test that verifies traversal is lazy -
it("should stop traversal if resume is not called", _ => {
const input = [ { id: 1 }, { id: 2 } ]
const actual = []
traverse(input, (node, resume) => {
actual.push(node.id)
// do not resume()
})
assert.strictEqual(actual, [1], "should only contain the first item")
})
Expand the snippet below to verify the the program in your own browser -
const head = t => t[0]
const tail = t => t.slice(1)
const empty = t => t.length < 1
function traverse (t, yield, resume = () => void 0) {
switch (t?.constructor) {
case Array:
if (empty(t))
return resume()
else
return traverse(head(t), yield, _ => traverse(tail(t), yield, resume))
case Object:
return yield(t, _ => traverse(t?.SubItems, yield) || resume())
}
}
function getIds (graph) {
const r = []
traverse(graph, (node, resume) =>
r.push(node.id) && resume()
)
return r
}
function findById (graph, id) {
let found = undefined
traverse(graph, (node, resume) =>
node.id === id ? (found = node) : resume()
)
return found
}
const mygraph =
[{id:1,SubItems:[{id:2},{id:3}]},{id:4},{id:5,SubItems:[{id:6,SubItems:[{id:7}]},{id:8}]}]
console.log(JSON.stringify(getIds(mygraph)))
console.log(findById(mygraph, 6))
I have the following function that performs the searches.
public static containsDeep = (text: string) => (value?: any): any => {
if (!value) {
return false;
}
const valueType = typeof value;
if (valueType === 'string') {
return value.toLowerCase().indexOf(text.toLowerCase()) > -1;
}
if (Array.isArray(value)) {
return value.some(VimboUtils.containsDeep(text));
}
if (valueType === 'object') {
return Object.values(value).some(VimboUtils.containsDeep(text));
}
return false;
// tslint:disable-next-line
};
public static searchDeepInArray(array: Array<any>, text: string): any {
if (!array || !text) {
return null;
}
return array.filter(VimboUtils.containsDeep(text));
}
example of an array I get that I search for:
const value = [
{
'config_vimbo': [
{
_id: '1',
title: 'Estrutura FrontEnd - Códigos',
path: '/apps/general-settings/frontend-structure-code',
hidden: 'this._rolesService.hide.isNotVimbo()'
},
{
_id: '2',
title: 'Unidade de medidas CTE',
path: '/apps/general-settings/units-measurement-cte',
hidden: 'this._rolesService.hide.isNotVimbo()'
}
]
},
{
'Sua equipe': [
{
_id: '1',
title: 'Gerencie usuários e permissões',
path: '/apps/general-settings/user-business',
hidden: '!this._rolesService.modules.canView("configuracao")'
}
]
},
{
'Ajustes da sua conta': [
{
_id: '1',
title: 'Unidades de medidas',
path: '/apps/general-settings/units-measurement',
hidden: '!this._rolesService.modules.canView("configuracao")'
}
]
}]
I call the function as follows:
searchDeepInArray(value,'argument');
what is happening that it is filtering, but only if I type the first whole word.
If I type a word that is in the middle of the sentence, it does not search correctly.
I couldn't identify where the error is.
Edit 1
I could reproduce the error better. What happens, when it has more than one item per position, when you type if the word has something that matches, it returns all the items of that possession where the word was found. And should return the position with only the corresponding items. see the example on this link, if I type recebimento comes the position with all items(2) https://i.imgur.com/Uj7MlvK.gif
Solution found, if someone has a better solution, you can leave your comment :)
public static searchDeepInArray(
array: Array<any>,
text: string,
field: string
): any {
if (!array || !text) {
return null;
}
return array.filter(this.containsDeep(text)).map(element => {
const idx = Object.keys(element).length
? Object.keys(element)[0]
: null;
if (!idx) {
return element;
}
return Object.assign({}, element, {
[idx]: element[idx].filter(
subElement =>
subElement[field]
.toLowerCase()
.indexOf(text.toLowerCase()) > -1
)
});
});
}
enter image description here
I want to implement image and editable caption with tiptap extension
https://discuss.prosemirror.net/t/figure-and-editable-caption/462
There was a very good example with ProseMirror, but is it difficult to achieve with tiptap?
Please tell me what code you should write if possible.
I attach the code that I wrote below.
The image and caption have been successfully added, but the caption cannot be edited yet.
// ./CustomImage.ts
// #ts-ignore
import { Node, Plugin } from 'tiptap'
// #ts-ignore
import { nodeInputRule } from 'tiptap-commands'
const IMAGE_INPUT_REGEX = /!\[(.+|:?)\]\((\S+)(?:(?:\s+)["'](\S+)["'])?\)/
export default class CustomImage extends Node {
get name () {
return 'customImage'
}
get schema () {
return {
attrs: {
src: {
default: null
},
alt: {
default: null
},
title: {
default: null
},
caption: {
default: null
}
},
group: 'block',
selectable: false,
draggable: true,
parseDOM: [
{
tag: 'figure'
},
[
{
tag: 'img[src]',
getAttrs: (dom: any) => ({
src: dom.getAttribute('src'),
title: dom.getAttribute('title'),
alt: dom.getAttribute('alt')
})
},
{
tag: 'figcaption'
}
]
],
toDOM: (node: any) => [
'figure',
[
'img',
{
src: node.attrs.src,
title: node.attrs.title,
alt: node.attrs.alt
}
],
[
'figcaption',
{
contenteditable: 'true'
},
node.attrs.caption
]
]
}
}
commands ({ type }: any) {
return (attrs: any) => (state: any, dispatch: any) => {
const { selection } = state
const position = selection.$cursor ? selection.$cursor.pos : selection.$to.pos
const node = type.create(attrs)
const transaction = state.tr.insert(position, node)
dispatch(transaction)
}
}
inputRules ({ type }: any) {
return [
nodeInputRule(IMAGE_INPUT_REGEX, type, (match: any) => {
const [, alt, src, title] = match
return {
src,
alt,
title
}
})
]
}
get plugins () {
return [
new Plugin({
props: {
handleDOMEvents: {
drop (view: any, event: any) {
const hasFiles = event.dataTransfer &&
event.dataTransfer.files &&
event.dataTransfer.files.length
if (!hasFiles) {
return
}
const images = Array
.from(event.dataTransfer.files)
.filter((file: any) => (/image/i).test(file.type))
if (images.length === 0) {
return
}
event.preventDefault()
const { schema } = view.state
const coordinates = view.posAtCoords({ left: event.clientX, top: event.clientY })
images.forEach((image: any) => {
const reader = new FileReader()
reader.onload = (readerEvent: any) => {
const node = schema.nodes.image.create({
src: readerEvent.target.result
})
const transaction = view.state.tr.insert(coordinates.pos, node)
view.dispatch(transaction)
}
reader.readAsDataURL(image)
})
}
}
}
})
]
}
}
You're missing a "hole" in the figure caption to make it editable. See https://prosemirror.net/docs/ref/#model.NodeSpec.toDOM. I implemented one of the solutions mentioned in the ProseMirror thread mentioned; however, the cursor can still enter text in between image and figurecaption. I would recommend using the last example with 3 schemas instead.
get schema() {
return {
content: "inline*",
attrs: {src: {default: ""}, title: {default: ""}},
group: 'block',
draggable: true,
isolating: true,
parseDOM: [{
tag: "figure",
contentElement: "figcaption",
getAttrs(dom) {
let img = dom.querySelector("img")
console.log(img, img.getAttribute('src'), img.getAttribute('alt'));
return {src: img.getAttribute('src'), title: img.getAttribute('alt')}
}
}],
toDOM: node => ["figure", ["img", node.attrs], ["figurecaption", 0]],
}
}
I am trying to set up a mutation with modified code from the Relay Todo example.
When I try to compile I get the following error:
-- GraphQL Validation Error -- AddCampaignMutation --
File: /Users/me/docker/relay/examples/todo/js/mutations/AddCampaignMutation.js
Error: Cannot query field "addCampaign" on type "Mutation".
Source:
>
> mutation AddCampaignMutation {addCampaign}
> ^^^
-- GraphQL Validation Error -- AddCampaignMutation --
File: /Users/me/docker/relay/examples/todo/js/mutations/AddCampaignMutation.js
Error: Unknown type "AddCampaignPayload". Did you mean "AddTodoPayload" or "RenameTodoPayload"?
Source:
>
> fragment AddCampaignMutationRelayQL on AddCampaignPayload #relay(pattern: true) {
> ^^
I have duplicated the Todo code so I don't know why the Todo mutation is working correctly but my new Campaign test isn't.
This is my database.js file, I have removed the Todo related items to make the document easier to read:
export class Campaign {}
export class User {}
// Mock authenticated ID
const VIEWER_ID = 'me';
// Mock user data
const viewer = new User();
viewer.id = VIEWER_ID;
const usersById = {
[VIEWER_ID]: viewer,
};
// Mock campaign data
const campaignsById = {};
const campaignIdsByUser = {
[VIEWER_ID]: [],
};
let nextCampaignId = 0;
addCampaign('Campaign1');
addCampaign('Campaign2');
addCampaign('Campaign3');
addCampaign('Campaign4');
export function addCampaign(text) {
const campaign = new Campaign();
//campaign.complete = !!complete;
campaign.id = `${nextCampaignId++}`;
campaign.text = text;
campaignsById[campaign.id] = campaign;
campaignIdsByUser[VIEWER_ID].push(campaign.id);
return campaign.id;
}
export function getCampaign(id) {
return campaignsById[id];
}
export function getCampaigns(status = 'any') {
const campaigns = campaignIdsByUser[VIEWER_ID].map(id => campaignsById[id]);
if (status === 'any') {
return campaigns;
}
return campaigns.filter(campaign => campaign.complete === (status === 'completed'));
}
This is my schema.js file, again I have removed the Todo related items to make the document easier to read:
import {
GraphQLBoolean,
GraphQLID,
GraphQLInt,
GraphQLList,
GraphQLNonNull,
GraphQLObjectType,
GraphQLSchema,
GraphQLString,
} from 'graphql';
import {
connectionArgs,
connectionDefinitions,
connectionFromArray,
cursorForObjectInConnection,
fromGlobalId,
globalIdField,
mutationWithClientMutationId,
nodeDefinitions,
toGlobalId,
} from 'graphql-relay';
import {
Campaign,
addCampaign,
getCampaign,
getCampaigns,
User,
getViewer,
} from './database';
const {nodeInterface, nodeField} = nodeDefinitions(
(globalId) => {
const {type, id} = fromGlobalId(globalId);
if (type === 'User') {
return getUser(id);
} else if (type === 'Campaign') {
return getCampaign(id);
}
return null;
},
(obj) => {
if (obj instanceof User) {
return GraphQLUser;
} else if (obj instanceof Campaign) {
return GraphQLCampaign;
}
return null;
}
);
/**
* Define your own connection types here
*/
const GraphQLAddCampaignMutation = mutationWithClientMutationId({
name: 'AddCampaign',
inputFields: {
text: { type: new GraphQLNonNull(GraphQLString) },
},
outputFields: {
campaignEdge: {
type: GraphQLCampaignEdge,
resolve: ({localCampaignId}) => {
const campaign = getCampaign(localCampaignId);
return {
cursor: cursorForObjectInConnection(getCampaigns(), campaign),
node: campaign,
};
},
},
viewer: {
type: GraphQLUser,
resolve: () => getViewer(),
},
},
mutateAndGetPayload: ({text}) => {
const localCampaignId = addCampaign(text);
return {localCampaignId};
},
});
const GraphQLCampaign = new GraphQLObjectType({
name: 'Campaign',
description: 'Campaign integrated in our starter kit',
fields: () => ({
id: globalIdField('Campaign'),
text: {
type: GraphQLString,
description: 'Name of the campaign',
resolve: (obj) => obj.text,
}
}),
interfaces: [nodeInterface]
});
const {
connectionType: CampaignsConnection,
edgeType: GraphQLCampaignEdge,
} = connectionDefinitions({
name: 'Campaign',
nodeType: GraphQLCampaign,
});
const GraphQLUser = new GraphQLObjectType({
name: 'User',
fields: {
id: globalIdField('User'),
campaigns: {
type: CampaignsConnection,
args: {
...connectionArgs,
},
resolve: (obj, {...args}) =>
connectionFromArray(getCampaigns(), args),
},
totalCount: {
type: GraphQLInt,
resolve: () => getTodos().length,
},
completedCount: {
type: GraphQLInt,
resolve: () => getTodos('completed').length,
},
},
interfaces: [nodeInterface],
});
const Root = new GraphQLObjectType({
name: 'Root',
fields: {
viewer: {
type: GraphQLUser,
resolve: () => getViewer(),
},
node: nodeField,
},
});
This is my AddCampaignMutation.js file:
import Relay from 'react-relay';
export default class AddCampaignMutation extends Relay.Mutation {
static fragments = {
viewer: () => Relay.QL`
fragment on User {
id,
totalCount,
}
`,
};
getMutation() {
console.log('getMutation');
return Relay.QL`mutation{addCampaign}`;
}
getFatQuery() {
console.log('getFatQuery');
return Relay.QL`
fragment on AddCampaignPayload #relay(pattern: true) {
campaignEdge,
viewer {
campaigns,
},
}
`;
}
getConfigs() {
console.log('getConfigs');
return [{
type: 'RANGE_ADD',
parentName: 'viewer',
parentID: this.props.viewer.id,
connectionName: 'campaigns',
edgeName: 'campaignEdge',
rangeBehaviors: ({orderby}) => {
if (orderby === 'newest') {
return 'prepend';
} else {
return 'append';
}
},
//rangeBehaviors: ({status}) => {
// if (status === 'completed') {
// return 'ignore';
// } else {
// return 'append';
// }
//},
}];
}
getVariables() {
console.log('getVariables');
return {
text: this.props.text,
};
}
getOptimisticResponse() {
console.log('getOptimisticResponse');
return {
// FIXME: totalCount gets updated optimistically, but this edge does not
// get added until the server responds
campaignEdge: {
node: {
text: this.props.text,
},
},
viewer: {
id: this.props.viewer.id,
totalCount: this.props.viewer.totalCount + 1,
},
};
}
}
And finally this is the app file that contains my text input and the call to AddCampaignMutation:
import AddTodoMutation from '../mutations/AddTodoMutation';
import AddCampaignMutation from '../mutations/AddCampaignMutation';
import TodoListFooter from './TodoListFooter';
import TodoTextInput from './TodoTextInput';
import React from 'react';
import Relay from 'react-relay';
class TodoApp extends React.Component {
_handleTextInputSave = (text) => {
debugger;
this.props.relay.commitUpdate(
new AddTodoMutation({text, viewer: this.props.viewer})
);
};
_campaignHandleTextInputSave = (text) => {
debugger;
this.props.relay.commitUpdate(
new AddCampaignMutation({text, viewer: this.props.viewer})
);
};
render() {
const hasTodos = this.props.viewer.totalCount > 0;
return (
<div>
<section className="todoapp">
<header className="header">
<TodoTextInput
autoFocus={true}
className="new-campaign"
onSave={this._campaignHandleTextInputSave}
placeholder="Campaign name"
/>
<h1>
todos
</h1>
<TodoTextInput
autoFocus={true}
className="new-todo"
onSave={this._handleTextInputSave}
placeholder="What needs to be done?"
/>
</header>
{this.props.children}
{hasTodos &&
<TodoListFooter
todos={this.props.viewer.todos}
viewer={this.props.viewer}
/>
}
</section>
</div>
);
}
}
export default Relay.createContainer(TodoApp, {
fragments: {
viewer: () => Relay.QL`
fragment on User {
totalCount,
${AddTodoMutation.getFragment('viewer')},
${AddCampaignMutation.getFragment('viewer')},
${TodoListFooter.getFragment('viewer')},
}
`,
},
});
Well I feel a bit silly but I found out what the problem was. I didn't realise that my schema.json file was not updating!
If anyone has a similar problem make sure that the schema.json file is up to date by running the following command to rebuild it:
npm run-script update-schema