diff options
| author | Armand Philippot <git@armandphilippot.com> | 2023-12-01 17:59:30 +0100 |
|---|---|---|
| committer | Armand Philippot <git@armandphilippot.com> | 2023-12-01 18:06:46 +0100 |
| commit | 11e3ee75fcab0ab54b2bc1713a402c5cc3070c2d (patch) | |
| tree | 7cb478ac6b29f2b527eb3ec379b305b74dd4f0ba | |
| parent | dfdbf6cac1fe3719dc71e130129d28e04ba4e225 (diff) | |
refactor(pages): refine Topic pages
* add useTopic and useTopicsList hooks to refresh data
* add a table of contents
* add Cypress tests
| -rw-r--r-- | src/components/organisms/posts-list/posts-list.tsx | 14 | ||||
| -rw-r--r-- | src/i18n/en.json | 12 | ||||
| -rw-r--r-- | src/i18n/fr.json | 14 | ||||
| -rw-r--r-- | src/pages/sujet/[slug].tsx | 202 | ||||
| -rw-r--r-- | src/services/graphql/helpers/convert-wp-topic-to-topic.ts | 3 | ||||
| -rw-r--r-- | src/styles/pages/blog.module.scss | 11 | ||||
| -rw-r--r-- | src/utils/hooks/index.ts | 2 | ||||
| -rw-r--r-- | src/utils/hooks/use-topic/index.ts | 1 | ||||
| -rw-r--r-- | src/utils/hooks/use-topic/use-topic.test.ts | 54 | ||||
| -rw-r--r-- | src/utils/hooks/use-topic/use-topic.ts | 28 | ||||
| -rw-r--r-- | src/utils/hooks/use-topics-list/index.ts | 1 | ||||
| -rw-r--r-- | src/utils/hooks/use-topics-list/use-topics-list.test.ts | 48 | ||||
| -rw-r--r-- | src/utils/hooks/use-topics-list/use-topics-list.ts | 46 | ||||
| -rw-r--r-- | tests/cypress/e2e/pages/topic.cy.ts | 38 |
14 files changed, 380 insertions, 94 deletions
diff --git a/src/components/organisms/posts-list/posts-list.tsx b/src/components/organisms/posts-list/posts-list.tsx index 783bc4e..c4c6fa1 100644 --- a/src/components/organisms/posts-list/posts-list.tsx +++ b/src/components/organisms/posts-list/posts-list.tsx @@ -40,10 +40,22 @@ export type PostData = Pick< Required<Pick<PostPreviewMetaData, 'publicationDate'>>; }; +/** + * Method to sort PageLink objects by name. + * + * @param {PageLink} a - A PageLink object. + * @param {PageLink} b - Another PageLink object. + * @returns {1 | -1 | 0} + */ +export const sortPostsByDate = (a: PostData, b: PostData) => + new Date(b.meta.publicationDate).getTime() - + new Date(a.meta.publicationDate).getTime(); + const getPostsByYear = (posts: PostData[]) => { const yearCollection = new Map<string, PostData[]>(); + const sortedPosts = [...posts].sort(sortPostsByDate); - for (const post of posts) { + for (const post of sortedPosts) { const currentPostYear = new Date(post.meta.publicationDate) .getFullYear() .toString(); diff --git a/src/i18n/en.json b/src/i18n/en.json index c449756..f760860 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -499,6 +499,10 @@ "defaultMessage": "Popularity:", "description": "ProjectOverview: popularity label" }, + "d+DOFQ": { + "defaultMessage": "Browse posts in {topicName} topic", + "description": "TopicPage: posts list heading" + }, "dDwm38": { "defaultMessage": "{website} picture", "description": "SiteBranding: photo alternative text" @@ -667,6 +671,10 @@ "defaultMessage": "Settings", "description": "SiteNavbar: settings modal title in navbar" }, + "uUIgCr": { + "defaultMessage": "Topics are loading...", + "description": "TopicPage: loading topics message" + }, "uZj4QI": { "defaultMessage": "Cancel reply", "description": "CommentsList: cancel reply button" @@ -699,10 +707,6 @@ "defaultMessage": "Message:", "description": "ContactForm: message label" }, - "zEN3fd": { - "defaultMessage": "All posts in {topicName}", - "description": "TopicPage: posts list heading" - }, "zbzlb1": { "defaultMessage": "Page {number}", "description": "BlogPage: page number" diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 9e5754a..9a098fc 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -32,7 +32,7 @@ "description": "MotionToggle: reduce motion label" }, "/sRqPT": { - "defaultMessage": "Thématiques liées", + "defaultMessage": "Thématiques connexes", "description": "TopicPage: related thematics list widget title" }, "/unaGZ": { @@ -499,6 +499,10 @@ "defaultMessage": "Popularité :", "description": "ProjectOverview: popularity label" }, + "d+DOFQ": { + "defaultMessage": "Parcourir les articles au sujet de {topicName}", + "description": "TopicPage: posts list heading" + }, "dDwm38": { "defaultMessage": "Photo d’{website}", "description": "SiteBranding: photo alternative text" @@ -667,6 +671,10 @@ "defaultMessage": "Réglages", "description": "SiteNavbar: settings modal title in navbar" }, + "uUIgCr": { + "defaultMessage": "Les sujets sont en cours de chargement…", + "description": "TopicPage: loading topics message" + }, "uZj4QI": { "defaultMessage": "Annuler la réponse", "description": "CommentsList: cancel reply button" @@ -699,10 +707,6 @@ "defaultMessage": "Message :", "description": "ContactForm: message label" }, - "zEN3fd": { - "defaultMessage": "Tous les articles dans {topicName}", - "description": "TopicPage: posts list heading" - }, "zbzlb1": { "defaultMessage": "Page {number}", "description": "BlogPage: page number" diff --git a/src/pages/sujet/[slug].tsx b/src/pages/sujet/[slug].tsx index 185756b..8a9c2f3 100644 --- a/src/pages/sujet/[slug].tsx +++ b/src/pages/sujet/[slug].tsx @@ -15,17 +15,24 @@ import { PageHeader, PageSidebar, PageBody, + LoadingPage, + TocWidget, + Spinner, } from '../../components'; import { convertWPTopicPreviewToPageLink, - convertWPTopicToTopic, fetchAllTopicsSlugs, fetchTopic, fetchTopicsCount, fetchTopicsList, } from '../../services/graphql'; import styles from '../../styles/pages/blog.module.scss'; -import type { NextPageWithLayout, PageLink, Topic } from '../../types'; +import type { + GraphQLConnection, + NextPageWithLayout, + WPTopic, + WPTopicPreview, +} from '../../types'; import { CONFIG } from '../../utils/config'; import { ROUTES } from '../../utils/constants'; import { @@ -34,21 +41,45 @@ import { getSchemaJson, getSinglePageSchema, getWebPageSchema, + slugify, } from '../../utils/helpers'; import { loadTranslation, type Messages } from '../../utils/helpers/server'; -import { useBreadcrumb } from '../../utils/hooks'; +import { + useBreadcrumb, + useHeadingsTree, + useTopic, + useTopicsList, +} from '../../utils/hooks'; export type TopicPageProps = { - currentTopic: Topic; - topics: PageLink[]; + data: { + currentTopic: WPTopic; + otherTopics: GraphQLConnection<WPTopicPreview>; + totalTopics: number; + }; translation: Messages; }; -const TopicPage: NextPageWithLayout<TopicPageProps> = ({ - currentTopic, - topics, -}) => { - const { content, intro, meta, slug, title } = currentTopic; +const TopicPage: NextPageWithLayout<TopicPageProps> = ({ data }) => { + const intl = useIntl(); + const { isFallback } = useRouter(); + const { isLoading, topic } = useTopic( + data.currentTopic.slug, + data.currentTopic + ); + const { isLoading: areTopicsLoading, topics } = useTopicsList({ + fallback: data.otherTopics, + input: { first: data.totalTopics, where: { notIn: [topic.id] } }, + }); + const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({ + title: topic.title, + url: `${ROUTES.TOPICS}/${topic.slug}`, + }); + const { ref, tree } = useHeadingsTree({ fromLevel: 2 }); + + if (isFallback || isLoading) return <LoadingPage />; + + const { content, intro, meta, slug, title } = topic; const { articles, cover, @@ -57,17 +88,11 @@ const TopicPage: NextPageWithLayout<TopicPageProps> = ({ relatedThematics, website: officialWebsite, } = meta; - const intl = useIntl(); - const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({ - title, - url: `${ROUTES.TOPICS}/${slug}`, - }); - const { asPath } = useRouter(); const webpageSchema = getWebPageSchema({ description: seo.description, locale: CONFIG.locales.defaultLocale, - slug: asPath, + slug, title: seo.title, updateDate: dates.update, }); @@ -78,30 +103,46 @@ const TopicPage: NextPageWithLayout<TopicPageProps> = ({ id: 'topic', kind: 'page', locale: CONFIG.locales.defaultLocale, - slug: asPath, + slug, title, }); const schemaJsonLd = getSchemaJson([webpageSchema, articleSchema]); - const topicsListTitle = intl.formatMessage({ - defaultMessage: 'Other topics', - description: 'TopicPage: other topics list widget title', - id: 'JpC3JH', - }); - - const thematicsListTitle = intl.formatMessage({ - defaultMessage: 'Related thematics', - description: 'TopicPage: related thematics list widget title', - id: '/sRqPT', - }); + const messages = { + widgets: { + loadingTopicsList: intl.formatMessage({ + defaultMessage: 'Topics are loading...', + description: 'TopicPage: loading topics message', + id: 'uUIgCr', + }), + thematicsListTitle: intl.formatMessage({ + defaultMessage: 'Related thematics', + description: 'TopicPage: related thematics list widget title', + id: '/sRqPT', + }), + tocTitle: intl.formatMessage({ + defaultMessage: 'Table of Contents', + description: 'PageLayout: table of contents title', + id: 'eys2uX', + }), + topicsListTitle: intl.formatMessage({ + defaultMessage: 'Other topics', + description: 'TopicPage: other topics list widget title', + id: 'JpC3JH', + }), + }, + browsePostsTitle: intl.formatMessage( + { + defaultMessage: 'Browse posts in {topicName} topic', + description: 'TopicPage: posts list heading', + id: 'd+DOFQ', + }, + { topicName: title } + ), + }; - const getPageHeading = () => ( - <> - {cover ? <NextImage {...cover} className={styles['topic-logo']} /> : null} - {title} - </> - ); - const pageUrl = `${CONFIG.url}${asPath}`; + const pageUrl = `${CONFIG.url}${slug}`; + const browsePostHeadingId = slugify(messages.browsePostsTitle); return ( <Page breadcrumbs={breadcrumbItems}> @@ -129,7 +170,14 @@ const TopicPage: NextPageWithLayout<TopicPageProps> = ({ type="application/ld+json" /> <PageHeader - heading={getPageHeading()} + heading={ + <> + {cover ? ( + <NextImage {...cover} className={styles['topic-logo']} /> + ) : null} + {title} + </> + } intro={intro} meta={{ publicationDate: dates.publication, @@ -138,23 +186,33 @@ const TopicPage: NextPageWithLayout<TopicPageProps> = ({ website: officialWebsite, }} /> - <PageBody className={styles.body}> - {/*eslint-disable-next-line react/no-danger -- Necessary for content*/} - {content ? <div dangerouslySetInnerHTML={{ __html: content }} /> : null} + <PageSidebar> + <TocWidget + heading={<Heading level={2}>{messages.widgets.tocTitle}</Heading>} + tree={[ + ...tree, + { + children: [], + depth: 2, + id: browsePostHeadingId, + label: messages.browsePostsTitle, + }, + ]} + /> + </PageSidebar> + <PageBody> + <div + className={styles.body} + // eslint-disable-next-line react/no-danger -- Necessary for content + dangerouslySetInnerHTML={{ __html: content }} + ref={ref} + /> {articles ? ( <> - <Heading level={2}> - {intl.formatMessage( - { - defaultMessage: 'All posts in {topicName}', - description: 'TopicPage: posts list heading', - id: 'zEN3fd', - }, - { topicName: title } - )} + <Heading id={browsePostHeadingId} level={2}> + {messages.browsePostsTitle} </Heading> <PostsList - className={styles.list} posts={getPostsWithUrl(articles)} headingLvl={3} sortByYear @@ -166,21 +224,25 @@ const TopicPage: NextPageWithLayout<TopicPageProps> = ({ {relatedThematics ? ( <LinksWidget heading={ - <Heading isFake level={3}> - {thematicsListTitle} - </Heading> + <Heading level={2}>{messages.widgets.thematicsListTitle}</Heading> } items={getLinksItemData(relatedThematics)} /> ) : null} - <LinksWidget - heading={ - <Heading isFake level={3}> - {topicsListTitle} - </Heading> - } - items={getLinksItemData(topics)} - /> + {areTopicsLoading ? ( + <Spinner>{messages.widgets.loadingTopicsList}</Spinner> + ) : ( + <LinksWidget + heading={ + <Heading level={2}>{messages.widgets.topicsListTitle}</Heading> + } + items={getLinksItemData( + topics.edges.map((edge) => + convertWPTopicPreviewToPageLink(edge.node) + ) + )} + /> + )} </PageSidebar> </Page> ); @@ -198,23 +260,19 @@ export const getStaticProps: GetStaticProps<TopicPageProps> = async ({ }) => { const currentTopic = await fetchTopic((params as TopicParams).slug); const totalTopics = await fetchTopicsCount(); - const allTopicsEdges = await fetchTopicsList({ + const otherTopics = await fetchTopicsList({ first: totalTopics, + where: { notIn: [currentTopic.databaseId] }, }); - const allTopics = allTopicsEdges.edges.map((edge) => - convertWPTopicPreviewToPageLink(edge.node) - ); - const topicsLinks = allTopics.filter( - (topic) => topic.url !== `${ROUTES.TOPICS}/${(params as TopicParams).slug}` - ); const translation = await loadTranslation(locale); return { props: { - currentTopic: JSON.parse( - JSON.stringify(convertWPTopicToTopic(currentTopic)) - ), - topics: JSON.parse(JSON.stringify(topicsLinks)), + data: { + currentTopic: JSON.parse(JSON.stringify(currentTopic)), + otherTopics: JSON.parse(JSON.stringify(otherTopics)), + totalTopics, + }, translation, }, }; diff --git a/src/services/graphql/helpers/convert-wp-topic-to-topic.ts b/src/services/graphql/helpers/convert-wp-topic-to-topic.ts index f3ed4a9..60e5548 100644 --- a/src/services/graphql/helpers/convert-wp-topic-to-topic.ts +++ b/src/services/graphql/helpers/convert-wp-topic-to-topic.ts @@ -3,6 +3,7 @@ import { ROUTES } from '../../../utils/constants'; import { getUniquePageLinks, sortPageLinksByName, + updateContentTree, } from '../../../utils/helpers'; import { convertPostPreviewToArticlePreview } from './convert-post-preview-to-article-preview'; import { convertWPThematicPreviewToPageLink } from './convert-taxonomy-to-page-link'; @@ -38,7 +39,7 @@ export const convertWPTopicToTopic = ({ title, }: WPTopic): Topic => { return { - content: contentParts.afterMore, + content: updateContentTree(contentParts.afterMore), id: databaseId, intro: contentParts.beforeMore, meta: { diff --git a/src/styles/pages/blog.module.scss b/src/styles/pages/blog.module.scss index d1819cd..e8d0034 100644 --- a/src/styles/pages/blog.module.scss +++ b/src/styles/pages/blog.module.scss @@ -7,17 +7,6 @@ @use "partials/article-media"; @use "partials/article-wp-blocks"; -.posts-list { - @include mix.media("screen") { - @include mix.dimensions("md") { - --col1: #{fun.convert-px(100)}; - --gap: var(--spacing-lg); - - margin-left: calc((var(--col1) + var(--gap)) * -1); - } - } -} - .sharing-widget { @include mix.media("screen") { @include mix.dimensions("md") { diff --git a/src/utils/hooks/index.ts b/src/utils/hooks/index.ts index 3fb0ad4..da4ed9e 100644 --- a/src/utils/hooks/index.ts +++ b/src/utils/hooks/index.ts @@ -27,3 +27,5 @@ export * from './use-thematics-list'; export * from './use-theme'; export * from './use-timeout'; export * from './use-toggle'; +export * from './use-topic'; +export * from './use-topics-list'; diff --git a/src/utils/hooks/use-topic/index.ts b/src/utils/hooks/use-topic/index.ts new file mode 100644 index 0000000..e87ab38 --- /dev/null +++ b/src/utils/hooks/use-topic/index.ts @@ -0,0 +1 @@ +export * from './use-topic'; diff --git a/src/utils/hooks/use-topic/use-topic.test.ts b/src/utils/hooks/use-topic/use-topic.test.ts new file mode 100644 index 0000000..e160a3e --- /dev/null +++ b/src/utils/hooks/use-topic/use-topic.test.ts @@ -0,0 +1,54 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, + jest, +} from '@jest/globals'; +import { renderHook, waitFor } from '@testing-library/react'; +import { wpTopicsFixture } from '../../../../tests/fixtures'; +import { ROUTES } from '../../constants'; +import { useTopic } from './use-topic'; + +describe('useTopic', () => { + beforeEach(() => { + /* Not sure why it is needed, but without it Jest was complaining with + * `Jest worker encountered 4 child process exceptions`... Maybe because of + * useSWR? */ + jest.useFakeTimers({ + doNotFake: ['queueMicrotask'], + }); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + /* eslint-disable max-statements */ + it('fetch the requested topic', async () => { + const { result } = renderHook(() => useTopic(wpTopicsFixture[0].slug)); + + // Inaccurate assertions count because of waitFor... + //expect.assertions(8); + expect.hasAssertions(); + + expect(result.current.topic).toBeUndefined(); + expect(result.current.isError).toBe(false); + expect(result.current.isLoading).toBe(true); + expect(result.current.isValidating).toBe(true); + + jest.advanceTimersToNextTimer(); + + await waitFor(() => + expect(result.current.topic?.slug).toBe( + `${ROUTES.TOPICS}/${wpTopicsFixture[0].slug}` + ) + ); + expect(result.current.isError).toBe(false); + expect(result.current.isLoading).toBe(false); + expect(result.current.isValidating).toBe(false); + }); + /* eslint-enable max-statements */ +}); diff --git a/src/utils/hooks/use-topic/use-topic.ts b/src/utils/hooks/use-topic/use-topic.ts new file mode 100644 index 0000000..bd7ee49 --- /dev/null +++ b/src/utils/hooks/use-topic/use-topic.ts @@ -0,0 +1,28 @@ +import useSWR from 'swr'; +import { convertWPTopicToTopic, fetchTopic } from '../../../services/graphql'; +import type { Maybe, Topic, WPTopic } from '../../../types'; + +export type UseTopicReturn<T extends Maybe<WPTopic>> = { + isError: boolean; + isLoading: boolean; + isValidating: boolean; + topic: T extends undefined ? Maybe<Topic> : Topic; +}; + +export const useTopic = <T extends Maybe<WPTopic>>( + slug: string, + fallback?: T +): UseTopicReturn<T> => { + const { data, error, isLoading, isValidating } = useSWR(slug, fetchTopic, { + fallbackData: fallback, + }); + + if (error) console.error(error); + + return { + isError: !!error, + isLoading, + isValidating, + topic: data ? convertWPTopicToTopic(data) : undefined, + } as UseTopicReturn<T>; +}; diff --git a/src/utils/hooks/use-topics-list/index.ts b/src/utils/hooks/use-topics-list/index.ts new file mode 100644 index 0000000..c08400f --- /dev/null +++ b/src/utils/hooks/use-topics-list/index.ts @@ -0,0 +1 @@ +export * from './use-topics-list'; diff --git a/src/utils/hooks/use-topics-list/use-topics-list.test.ts b/src/utils/hooks/use-topics-list/use-topics-list.test.ts new file mode 100644 index 0000000..c8fa607 --- /dev/null +++ b/src/utils/hooks/use-topics-list/use-topics-list.test.ts @@ -0,0 +1,48 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, + jest, +} from '@jest/globals'; +import { renderHook, waitFor } from '@testing-library/react'; +import { useTopicsList } from './use-topics-list'; + +describe('useTopicsList', () => { + beforeEach(() => { + /* Not sure why it is needed, but without it Jest was complaining with + * `Jest worker encountered 4 child process exceptions`... Maybe because of + * useSWR? */ + jest.useFakeTimers({ + doNotFake: ['queueMicrotask'], + }); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + /* eslint-disable max-statements */ + it('fetch the requested topics list', async () => { + const { result } = renderHook(() => useTopicsList()); + + // Inaccurate assertions count because of waitFor... + //expect.assertions(8); + expect.hasAssertions(); + + expect(result.current.topics).toBeUndefined(); + expect(result.current.isError).toBe(false); + expect(result.current.isLoading).toBe(true); + expect(result.current.isValidating).toBe(true); + + jest.advanceTimersToNextTimer(); + + await waitFor(() => expect(result.current.topics).toBeDefined()); + expect(result.current.isError).toBe(false); + expect(result.current.isLoading).toBe(false); + expect(result.current.isValidating).toBe(false); + }); + /* eslint-enable max-statements */ +}); diff --git a/src/utils/hooks/use-topics-list/use-topics-list.ts b/src/utils/hooks/use-topics-list/use-topics-list.ts new file mode 100644 index 0000000..7860486 --- /dev/null +++ b/src/utils/hooks/use-topics-list/use-topics-list.ts @@ -0,0 +1,46 @@ +import useSWR from 'swr'; +import { + type FetchTopicsListInput, + fetchTopicsList, +} from '../../../services/graphql'; +import type { GraphQLConnection, Maybe, WPTopicPreview } from '../../../types'; + +export type UseTopicsListReturn< + T extends Maybe<GraphQLConnection<WPTopicPreview>>, +> = { + isError: boolean; + isLoading: boolean; + isValidating: boolean; + topics: T extends undefined + ? Maybe<GraphQLConnection<WPTopicPreview>> + : GraphQLConnection<WPTopicPreview>; +}; + +export type UseTopicsListConfig< + T extends Maybe<GraphQLConnection<WPTopicPreview>>, +> = { + input?: FetchTopicsListInput; + fallback?: T; +}; + +export const useTopicsList = < + T extends Maybe<GraphQLConnection<WPTopicPreview>>, +>( + config?: UseTopicsListConfig<T> +): UseTopicsListReturn<T> => { + const { fallback, input } = config ?? {}; + const { data, error, isLoading, isValidating } = useSWR( + input ?? {}, + fetchTopicsList, + { fallbackData: fallback } + ); + + if (error) console.error(error); + + return { + isError: !!error, + isLoading, + isValidating, + topics: data, + } as UseTopicsListReturn<T>; +}; diff --git a/tests/cypress/e2e/pages/topic.cy.ts b/tests/cypress/e2e/pages/topic.cy.ts new file mode 100644 index 0000000..3b30893 --- /dev/null +++ b/tests/cypress/e2e/pages/topic.cy.ts @@ -0,0 +1,38 @@ +describe('Topic', () => { + beforeEach(() => { + cy.visit('/sujet/docker'); + }); + + it('successfully loads', () => { + cy.findByRole('heading', { level: 1 }).should('exist'); + }); + + it('contains the topic meta', () => { + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + cy.findAllByRole('term').should('have.length.at.least', 2); + + /* The accessible name is not recognized while it should be the `dt` text + * content */ + /* cy.findByRole('term', { name: 'Publié le :' }).should('exist'); + cy.findByRole('term', { name: 'Total :' }).should('exist'); */ + }); + + it('contains a breadcrumbs', () => { + cy.findByRole('navigation', { name: 'Fil d’Ariane' }).should('exist'); + }); + + it('contains a table of contents', () => { + cy.findByRole('heading', { level: 2, name: 'Table des matières' }).should( + 'exist' + ); + }); + + it('contains a thematics list widget and a topics list widget', () => { + cy.findByRole('heading', { level: 2, name: 'Thématiques connexes' }).should( + 'exist' + ); + cy.findByRole('heading', { level: 2, name: 'Autres sujets' }).should( + 'exist' + ); + }); +}); |
