From b1cd50c2e6e08317abeab0d6005541c7137954ba Mon Sep 17 00:00:00 2001 From: neopostmodern Date: Thu, 4 Aug 2022 08:17:34 +0200 Subject: [PATCH] feat: reject saves when updatedAt is stale, closes #155 --- client/src/renderer/components/LinkForm.tsx | 1 + .../components/NetworkOperationsIndicator.tsx | 8 +++++++- client/src/renderer/components/TagForm.tsx | 18 +++++++++++------- client/src/renderer/components/TextForm.tsx | 7 ++++++- client/src/renderer/containers/LinkPage.tsx | 6 +++++- client/src/renderer/containers/TagPage.tsx | 4 ++++ client/src/renderer/containers/TextPage.tsx | 8 +++++++- client/src/renderer/generated/LinkQuery.ts | 5 +++-- client/src/renderer/generated/TextQuery.ts | 5 +++-- client/src/renderer/reducers/links.ts | 3 +++ schema.graphql | 3 +++ server/lib/resolvers.js | 18 ++++++++++++++++++ server/lib/schema.js | 4 +++- 13 files changed, 74 insertions(+), 16 deletions(-) diff --git a/client/src/renderer/components/LinkForm.tsx b/client/src/renderer/components/LinkForm.tsx index 0a45371f..dcebad7d 100644 --- a/client/src/renderer/components/LinkForm.tsx +++ b/client/src/renderer/components/LinkForm.tsx @@ -13,6 +13,7 @@ import MarkedTextarea from './MarkedTextarea'; const linkFormFields: Array = [ '_id', + 'updatedAt', 'url', 'name', 'description', diff --git a/client/src/renderer/components/NetworkOperationsIndicator.tsx b/client/src/renderer/components/NetworkOperationsIndicator.tsx index 2d5d39d0..0304e277 100644 --- a/client/src/renderer/components/NetworkOperationsIndicator.tsx +++ b/client/src/renderer/components/NetworkOperationsIndicator.tsx @@ -2,6 +2,7 @@ import { MutationResult } from '@apollo/client'; import { Box, Typography } from '@mui/material'; import { PropsWithChildren, useEffect, useState } from 'react'; import { DataState, PolicedData } from '../utils/useDataState'; +import ErrorSnackbar from './ErrorSnackbar'; enum NetworkPhase { IDLE, @@ -79,7 +80,12 @@ const NetworkOperationsIndicator = ({ message = 'Up to date.'; } - return {message}; + return ( + <> + {message} + + + ); }; export default NetworkOperationsIndicator; diff --git a/client/src/renderer/components/TagForm.tsx b/client/src/renderer/components/TagForm.tsx index 011be9af..d8e1542a 100644 --- a/client/src/renderer/components/TagForm.tsx +++ b/client/src/renderer/components/TagForm.tsx @@ -2,7 +2,7 @@ import { pick } from 'lodash'; import React from 'react'; import { useForm } from 'react-hook-form'; import styled from 'styled-components'; -import { TagType } from '../types'; +import { TagObject } from '../reducers/links'; import colorTools from '../utils/colorTools'; import useSaveOnUnmount from '../utils/useSaveOnUnmount'; import { TextField } from './CommonStyles'; @@ -16,19 +16,23 @@ const ColorBlockInput = styled(TextField)` text-align: center; `; -const tagFormFields = ['_id', 'color', 'name']; +const tagFormFields: Array = [ + '_id', + 'updatedAt', + 'color', + 'name', +]; +type TagInForm = Pick; interface TagFormProps { - tag: TagType; - onSubmit: (tag: TagType) => void; + tag: TagObject; + onSubmit: (tag: TagObject) => void; } const TagForm: React.FC = ({ tag, onSubmit }) => { const defaultValues = pick(tag, tagFormFields); - const { register, getValues, handleSubmit } = useForm< - Pick - >({ + const { register, getValues, handleSubmit } = useForm({ defaultValues, mode: 'onBlur', resolver: (formValues) => { diff --git a/client/src/renderer/components/TextForm.tsx b/client/src/renderer/components/TextForm.tsx index 532ad9e2..7a46b3c4 100644 --- a/client/src/renderer/components/TextForm.tsx +++ b/client/src/renderer/components/TextForm.tsx @@ -8,7 +8,12 @@ import { NameInput } from './formComponents'; import Gap from './Gap'; import MarkedTextarea from './MarkedTextarea'; -const textFormFields: Array = ['_id', 'name', 'description']; +const textFormFields: Array = [ + '_id', + 'updatedAt', + 'name', + 'description', +]; type TextInForm = Pick; interface TextFormProps { diff --git a/client/src/renderer/containers/LinkPage.tsx b/client/src/renderer/containers/LinkPage.tsx index 91914c5c..5e9da2a1 100644 --- a/client/src/renderer/containers/LinkPage.tsx +++ b/client/src/renderer/containers/LinkPage.tsx @@ -22,6 +22,7 @@ const LINK_QUERY = gql` link(linkId: $linkId) { _id createdAt + updatedAt archivedAt url name @@ -41,6 +42,7 @@ const UPDATE_LINK_MUTATION = gql` updateLink(link: $link) { _id createdAt + updatedAt url domain name @@ -71,7 +73,9 @@ const LinkPage: React.FC = () => { >(UPDATE_LINK_MUTATION); const handleSubmit = useCallback( (updatedLink): void => { - updateLink({ variables: { link: updatedLink } }); + updateLink({ variables: { link: updatedLink } }).catch((error) => { + console.error('[LinkPage.updateLink]', error); + }); }, [updateLink] ); diff --git a/client/src/renderer/containers/TagPage.tsx b/client/src/renderer/containers/TagPage.tsx index a52e6382..7827207b 100644 --- a/client/src/renderer/containers/TagPage.tsx +++ b/client/src/renderer/containers/TagPage.tsx @@ -21,6 +21,7 @@ const TAG_QUERY = gql` query TagWithNotesQuery($tagId: ID!) { tag(tagId: $tagId) { _id + updatedAt name color @@ -50,6 +51,7 @@ const UPDATE_TAG_MUTATION = gql` mutation UpdateTagMutation($tag: InputTag!) { updateTag(tag: $tag) { _id + updatedAt name color @@ -93,6 +95,8 @@ const TagPage: React.FC<{}> = () => { (updatedTag): void => { updateTag({ variables: { tag: updatedTag }, + }).catch((error) => { + console.error('[TagPage.updateTag]', error); }); }, [updateTag] diff --git a/client/src/renderer/containers/TextPage.tsx b/client/src/renderer/containers/TextPage.tsx index e77c1a76..a777e192 100644 --- a/client/src/renderer/containers/TextPage.tsx +++ b/client/src/renderer/containers/TextPage.tsx @@ -22,6 +22,7 @@ const TEXT_QUERY = gql` text(textId: $textId) { _id createdAt + updatedAt archivedAt name description @@ -39,6 +40,7 @@ const UPDATE_TEXT_MUTATION = gql` updateText(text: $text) { _id createdAt + updatedAt archivedAt name description @@ -65,7 +67,11 @@ const TextPage: React.FC = () => { UpdateTextMutationVariables >(UPDATE_TEXT_MUTATION); const handleSubmit = useCallback( - (updatedText) => updateText({ variables: { text: updatedText } }), + (updatedText) => { + updateText({ variables: { text: updatedText } }).catch((error) => { + console.error('[TextPage.updateText]', error); + }); + }, [updateText] ); diff --git a/client/src/renderer/generated/LinkQuery.ts b/client/src/renderer/generated/LinkQuery.ts index f02c5b52..e3129ac0 100644 --- a/client/src/renderer/generated/LinkQuery.ts +++ b/client/src/renderer/generated/LinkQuery.ts @@ -8,16 +8,17 @@ // ==================================================== export interface LinkQuery_link_tags { - __typename: "Tag"; + __typename: 'Tag'; _id: string; name: string; color: string; } export interface LinkQuery_link { - __typename: "Link"; + __typename: 'Link'; _id: string; createdAt: any; + updatedAt: any; archivedAt: any | null; url: string; name: string; diff --git a/client/src/renderer/generated/TextQuery.ts b/client/src/renderer/generated/TextQuery.ts index cbcaeff4..678191f3 100644 --- a/client/src/renderer/generated/TextQuery.ts +++ b/client/src/renderer/generated/TextQuery.ts @@ -8,17 +8,18 @@ // ==================================================== export interface TextQuery_text_tags { - __typename: "Tag"; + __typename: 'Tag'; _id: string; name: string; color: string; } export interface TextQuery_text { - __typename: "Text"; + __typename: 'Text'; _id: string; createdAt: any; archivedAt: any | null; + updatedAt: any; name: string; description: string; tags: TextQuery_text_tags[]; diff --git a/client/src/renderer/reducers/links.ts b/client/src/renderer/reducers/links.ts index 8534af01..4eb3b367 100644 --- a/client/src/renderer/reducers/links.ts +++ b/client/src/renderer/reducers/links.ts @@ -1,5 +1,7 @@ export type TagObject = { _id: string; + createdAt: number; + updatedAt: number; name: string; color: string; }; @@ -12,5 +14,6 @@ export type NoteObject = { description: string; tags: Array; createdAt: number; // todo: Date + updatedAt: number; // todo: Date archivedAt: number | null; // todo: Date }; diff --git a/schema.graphql b/schema.graphql index 406e1484..79c7fdac 100644 --- a/schema.graphql +++ b/schema.graphql @@ -31,6 +31,7 @@ input InputLink { domain: String name: String path: String + updatedAt: Date url: String } @@ -38,12 +39,14 @@ input InputTag { _id: ID! color: String! name: String! + updatedAt: Date } input InputText { _id: ID! description: String name: String + updatedAt: Date } type Link implements BaseObject & INote { diff --git a/server/lib/resolvers.js b/server/lib/resolvers.js index e1d0e331..d148fd5d 100644 --- a/server/lib/resolvers.js +++ b/server/lib/resolvers.js @@ -152,6 +152,12 @@ const rootResolvers = { throw new Error('Resource could not be found.') } + if (tag.updatedAt > props.updatedAt) { + throw new Error( + 'Tag has been changed externally since last sync, change rejected.', + ) + } + Object.entries(props).forEach(([propName, propValue]) => { tag[propName] = propValue }) @@ -173,6 +179,12 @@ const rootResolvers = { throw new Error('Resource could not be found.') } + if (link.updatedAt > props.updatedAt) { + throw new Error( + 'Note has been changed externally since last sync, change rejected.', + ) + } + Object.entries(props).forEach(([propName, propValue]) => { link[propName] = propValue }) @@ -199,6 +211,12 @@ const rootResolvers = { throw new Error('Resource could not be found.') } + if (text.updatedAt > props.updatedAt) { + throw new Error( + 'Note has been changed externally since last sync, change rejected.', + ) + } + Object.entries(props).forEach(([propName, propValue]) => { text[propName] = propValue }) diff --git a/server/lib/schema.js b/server/lib/schema.js index 62927654..80f140b8 100644 --- a/server/lib/schema.js +++ b/server/lib/schema.js @@ -43,7 +43,7 @@ export default gql` } input InputTag { _id: ID! - + updatedAt: Date # todo: make mandatory in v0.21 name: String! color: String! } @@ -88,6 +88,7 @@ export default gql` } input InputText { _id: ID! + updatedAt: Date # todo: make mandatory in v0.21 name: String description: String } @@ -115,6 +116,7 @@ export default gql` } input InputLink { _id: ID! + updatedAt: Date # todo: make mandatory in v0.21 url: String domain: String path: String