diff --git a/client/src/graphql/client.js b/client/src/graphql/client.js index 78b35863..cca47bb2 100644 --- a/client/src/graphql/client.js +++ b/client/src/graphql/client.js @@ -9,6 +9,7 @@ import {typeDefs} from '@/graphql/typedefs'; import {resolvers} from '@/graphql/resolvers'; import cache from './cache'; +import {router} from '@/router'; export default function (uri, networkErrorCallback) { const httpLink = createHttpLink({ @@ -64,19 +65,37 @@ export default function (uri, networkErrorCallback) { } }); + const notFoundLink = new ApolloLink((operation, forward) => { + return forward(operation).map(response => { + const {data} = response; + if (data) { + if (data.topic && data.topic.__typename === 'NotFound') { + // redirect to general 404 page. + // todo: specific topic not found page, with navigation? + router.push({ + name: 'not-found' + }); + } + } + return response; + }); + }); + let composedLink; if (process.env.NODE_ENV === 'production') { composedLink = ApolloLink.from([ createOmitTypenameLink, errorLink, - httpLink + notFoundLink, + httpLink, ]); } else { composedLink = ApolloLink.from([ consoleLink, createOmitTypenameLink, errorLink, - httpLink + notFoundLink, + httpLink, ]); } diff --git a/client/src/graphql/gql/fragments/notFoundParts.gql b/client/src/graphql/gql/fragments/notFoundParts.gql new file mode 100644 index 00000000..75febb60 --- /dev/null +++ b/client/src/graphql/gql/fragments/notFoundParts.gql @@ -0,0 +1,3 @@ +fragment NotFoundParts on NotFound { + reason +} diff --git a/client/src/graphql/gql/queries/topicQuery.gql b/client/src/graphql/gql/queries/topicQuery.gql index 0537b5f3..37bce375 100644 --- a/client/src/graphql/gql/queries/topicQuery.gql +++ b/client/src/graphql/gql/queries/topicQuery.gql @@ -1,6 +1,8 @@ #import "../fragments/topicParts.gql" +#import "../fragments/notFoundParts.gql" query Topic($slug: String!){ topic(slug: $slug) { ...TopicParts + ...NotFoundParts } } diff --git a/client/src/pages/topic-page.vue b/client/src/pages/topic-page.vue index 4bb85519..47acb63c 100644 --- a/client/src/pages/topic-page.vue +++ b/client/src/pages/topic-page.vue @@ -63,7 +63,7 @@ BookTopicNavigation, ModuleTeaser, PlayIcon, - BulbIcon + BulbIcon, }, apollo: { @@ -71,7 +71,7 @@ return { query: TOPIC_QUERY, variables: { - slug: this.$route.params.topicSlug + slug: this.$route.params.topicSlug, }, update(data) { return this.$getRidOfEdges(data).topic || {}; @@ -81,26 +81,26 @@ this.saveMe = false; this.updateLastVisitedTopic(this.topic.id); } - } + }, }; - } + }, }, data() { return { topic: { modules: { - edges: [] - } + edges: [], + }, }, - saveMe: false + saveMe: false, }; }, computed: { modules() { return this.topic.modules; - } + }, }, mounted() { @@ -116,12 +116,15 @@ this.$store.dispatch('showFullscreenVideo', this.topic.vimeoId); }, updateLastVisitedTopic(topicId) { + if (!topicId) { + return; + } this.$apollo.mutate({ mutation: UPDATE_LAST_TOPIC_MUTATION, variables: { input: { - id: topicId - } + id: topicId, + }, }, update(store, {data: {updateLastTopic: {topic}}}) { if (topic) { @@ -131,15 +134,15 @@ const data = { me: { ...me, - lastTopic: topic - } + lastTopic: topic, + }, }; store.writeQuery({query, data}); } } - } + }, }); - } + }, }, }; diff --git a/client/src/router/index.js b/client/src/router/index.js index 55ce0676..20d61063 100644 --- a/client/src/router/index.js +++ b/client/src/router/index.js @@ -33,11 +33,18 @@ const submission = () => import('@/pages/studentSubmission'); const postLoginRedirectUrlKey = 'postLoginRedirectionUrl'; +const notFoundRoute = { + component: p404, + meta: { + layout: 'blank', + }, +}; + const routes = [ { path: '/', name: 'home', - component: start + component: start, }, ...moduleRoutes, ...authRoutes, @@ -85,15 +92,17 @@ const routes = [ default: return '/unknown-auth-error'; } - } + }, }, {path: '/styleguide', component: styleGuidePage}, + { + path: '/not-found', + name: 'not-found', + ...notFoundRoute + }, { path: '*', - component: p404, - meta: { - layout: 'blank', - }, + ...notFoundRoute }, ]; diff --git a/server/books/schema/nodes/topic.py b/server/books/schema/nodes/topic.py index 067120f2..d0b0be52 100644 --- a/server/books/schema/nodes/topic.py +++ b/server/books/schema/nodes/topic.py @@ -6,6 +6,14 @@ from graphene_django.filter import DjangoFilterConnectionField from books.models import Topic, Module from books.schema.nodes import ModuleNode +class NotFoundFailure: + reason = 'Not Found' + + + +class NotFound(graphene.ObjectType): + reason = graphene.String() + class TopicNode(DjangoObjectType): pk = graphene.Int() @@ -27,3 +35,14 @@ class TopicNode(DjangoObjectType): def resolve_modules(self, *args, **kwargs): return Module.get_by_parent(self) + + +class TopicOr404Node(graphene.Union): + class Meta: + types = (TopicNode, NotFound) + + @classmethod + def resolve_type(cls, instance, info): + if type(instance).__name__ == "Topic": + return TopicNode + return NotFound diff --git a/server/books/schema/queries.py b/server/books/schema/queries.py index 3db2b931..33267d8c 100644 --- a/server/books/schema/queries.py +++ b/server/books/schema/queries.py @@ -5,7 +5,8 @@ from graphene_django.filter import DjangoFilterConnectionField from api.utils import get_object from core.logger import get_logger from .connections import TopicConnection, ModuleConnection -from .nodes import ContentBlockNode, ChapterNode, ModuleNode, TopicNode, SnapshotNode +from .nodes import ContentBlockNode, ChapterNode, ModuleNode, NotFoundFailure, SnapshotNode, \ + TopicOr404Node from ..models import Book, Topic, Module, Chapter, Snapshot logger = get_logger(__name__) @@ -13,7 +14,7 @@ logger = get_logger(__name__) class BookQuery(object): node = relay.Node.Field() - topic = graphene.Field(TopicNode, slug=graphene.String()) + topic = graphene.Field(TopicOr404Node, slug=graphene.String()) module = graphene.Field(ModuleNode, slug=graphene.String(), id=graphene.ID()) chapter = relay.Node.Field(ChapterNode) content_block = relay.Node.Field(ContentBlockNode) @@ -63,5 +64,8 @@ class BookQuery(object): if id is not None: return get_object(Topic, id) if slug is not None: - return Topic.objects.get(slug=slug) + try: + return Topic.objects.get(slug=slug) + except Topic.DoesNotExist: + return NotFoundFailure return None diff --git a/server/books/tests/test_404.py b/server/books/tests/test_404.py new file mode 100644 index 00000000..116bd2f8 --- /dev/null +++ b/server/books/tests/test_404.py @@ -0,0 +1,33 @@ +from graphql.error import GraphQLLocatedError + +from core.tests.base_test import SkillboxTestCase + +TOPIC_QUERY = """ +query TopicQuery($slug: String!) { + topic(slug: $slug) { + __typename + ...on TopicNode { + title + } + ...on NotFound { + reason + } + } +} +""" + + +class ContentBlockTestCase(SkillboxTestCase): + def setUp(self) -> None: + self.createDefault() + self.client = self.get_client() + + def test_topic(self): + slug = "non-existing" + result = self.client.execute(TOPIC_QUERY, variables={ + "slug": slug + }) + self.assertIsNone(result.get('errors')) + topic = result.get('data').get('topic') + self.assertEqual(topic.get('__typename'), 'NotFound') + self.assertEqual(topic.get('reason'), 'Not Found') diff --git a/server/schema.graphql b/server/schema.graphql index 7a30217b..a42045bb 100644 --- a/server/schema.graphql +++ b/server/schema.graphql @@ -702,6 +702,10 @@ interface Node { id: ID! } +type NotFound { + reason: String +} + type NoteNode implements Node { id: ID! text: String! @@ -852,7 +856,7 @@ type Query { assignment(id: ID!): AssignmentNode assignments: [AssignmentNode] node(id: ID!): Node - topic(slug: String): TopicNode + topic(slug: String): TopicOr404Node module(slug: String, id: ID): ModuleNode chapter(id: ID!): ChapterNode contentBlock(id: ID!): ContentBlockNode @@ -1127,6 +1131,8 @@ type TopicNode implements Node { modules(offset: Int, before: String, after: String, first: Int, last: Int, slug: String, slug_Icontains: String, slug_In: [String], title: String, title_Icontains: String, title_In: [String]): ModuleNodeConnection } +union TopicOr404Node = TopicNode | NotFound + scalar UUID input UpdateAnswerArgument {