Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
199 changes: 199 additions & 0 deletions config/datocms/migrations/1784902160_faqBlock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import { Client, SimpleSchemaTypes } from '@datocms/cli/lib/cma-client-node';

export default async function (client: Client) {
//@ts-expect-error rich_text_blocks is only available on Modular Content fields
const homeBodyBlocks = (await client.fields.find('home_page::body_blocks')).validators.rich_text_blocks?.item_types as string[];
//@ts-expect-error rich_text_blocks is only available on Modular Content fields
const pageBodyBlocks = (await client.fields.find('page::body_blocks')).validators.rich_text_blocks?.item_types as string[];

console.log('Create new models/block models');

console.log('Create block model "\u2753 FAQ Block" (`faq_block`)');
const faqBlock = await client.itemTypes.create(
{
id: 'bwnEedmZRLCodyteEudcIQ',
name: '\u2753 FAQ Block',
api_key: 'faq_block',
modular_block: true,
draft_saving_active: false,
hint: '',
inverse_relationships_enabled: false,
},
{
skip_menu_item_creation: true,
schema_menu_item_id: 'PQtosMOrR_2nUjIwyGI5-w',
},
);

console.log('Create model "\u2753 FAQ" (`faq`)');
await client.itemTypes.create(
{
id: 'Wp8-9-o4Tc2uFPn01WwxYQ',
name: '\u2753 FAQ',
api_key: 'faq',
draft_mode_active: true,
draft_saving_active: false,
collection_appearance: 'table',
inverse_relationships_enabled: false,
},
{
skip_menu_item_creation: true,
schema_menu_item_id: 'XAz-FlSfTumPrm_vuq20lQ',
},
);

console.log('Creating new fields/fieldsets');

console.log(
'Create Single-line string field "Group title" (`group_title`) in block model "\u2753 FAQ Block" (`faq_block`)',
);
await client.fields.create('bwnEedmZRLCodyteEudcIQ', {
id: 'FuciaLmuRR6sm_G3eZ1kaA',
label: 'Group title',
field_type: 'string',
api_key: 'group_title',
appearance: {
addons: [],
editor: 'single_line',
parameters: { heading: false, placeholder: null },
},
default_value: null,
});

console.log(
'Create Multiple links field "Question and Answers" (`question_and_answers`) in block model "\u2753 FAQ Block" (`faq_block`)',
);
await client.fields.create('bwnEedmZRLCodyteEudcIQ', {
id: 'DmOR-2ehTOyylvEt0S6NJg',
label: 'Question and Answers',
field_type: 'links',
api_key: 'question_and_answers',
validators: {
items_item_type: {
on_publish_with_unpublished_references_strategy: 'fail',
on_reference_unpublish_strategy: 'delete_references',
on_reference_delete_strategy: 'delete_references',
item_types: ['Wp8-9-o4Tc2uFPn01WwxYQ'],
},
},
appearance: {
addons: [],
editor: 'links_embed',
parameters: { filters: [] },
},
default_value: null,
});

console.log(
'Create Slug field "Slug" (`slug`) in model "\u2753 FAQ" (`faq`)',
);
await client.fields.create('Wp8-9-o4Tc2uFPn01WwxYQ', {
id: 'Cs6T765xTlanqEV6ZB01uQ',
label: 'Slug',
field_type: 'slug',
api_key: 'slug',
validators: {
slug_format: { predefined_pattern: 'webpage_slug' },
required: {},
unique: {},
},
appearance: {
addons: [],
editor: 'slug',
parameters: { url_prefix: null, placeholder: null },
},
default_value: null,
});

console.log(
'Create Modular Content (Single block) field "Question and Answer" (`question_and_answer`) in model "\u2753 FAQ" (`faq`)',
);
// The answer links to the existing "Accordion Item" block model, whose id
// differs per environment. Resolve it by api_key instead of hardcoding.
const accordionItem = (await client.itemTypes.list()).find(
(itemType: SimpleSchemaTypes.ItemType) => itemType.api_key === 'accordion_item',
);
if (!accordionItem) {
throw new Error(
'Could not find the "accordion_item" block model required by the FAQ "Question and Answer" field.',
);
}
await client.fields.create('Wp8-9-o4Tc2uFPn01WwxYQ', {
id: 'ObBfAPRKQKqulCO-p4ZiAw',
label: 'Question and Answer',
field_type: 'single_block',
api_key: 'question_and_answer',
validators: {
single_block_blocks: { item_types: [accordionItem.id] },
required: {},
},
appearance: {
addons: [],
editor: 'framed_single_block',
parameters: { start_collapsed: false },
},
default_value: null,
});

console.log('Update existing fields/fieldsets');

console.log(
'Update Modular Content (Multiple blocks) field "Body" (`body_blocks`) in model "\ud83d\udcd1 Page" (`page`)',
);
await client.fields.update('Q-z1nyMsQtC8Sr6w6J2oGw', {
validators: {
rich_text_blocks: {
item_types: [...pageBodyBlocks, faqBlock.id],
},
},
});

console.log(
'Update Modular Content (Multiple blocks) field "Body" (`body_blocks`) in model "\ud83c\udfe0 Home" (`home_page`)',
);
await client.fields.update('pUj2PObgTyC-8X4lvZLMBA', {
validators: {
rich_text_blocks: {
item_types: [...homeBodyBlocks, faqBlock.id],
},
},
});

console.log('Finalize models/block models');

console.log('Update block model "\u2753 FAQ Block" (`faq_block`)');
await client.itemTypes.update('bwnEedmZRLCodyteEudcIQ', {
presentation_title_field: { id: 'FuciaLmuRR6sm_G3eZ1kaA', type: 'field' },
});

console.log('Update model "\u2753 FAQ" (`faq`)');
await client.itemTypes.update('Wp8-9-o4Tc2uFPn01WwxYQ', {
presentation_title_field: { id: 'ObBfAPRKQKqulCO-p4ZiAw', type: 'field' },
});

console.log('Manage menu items');

console.log('Create menu item "\u2753 FAQ"');
await client.menuItems.create({
id: 'c5jZf1KyTsCmRfzpTIwYAA',
label: '\u2753 FAQ',
item_type: { id: 'Wp8-9-o4Tc2uFPn01WwxYQ', type: 'item_type' },
});

console.log('Update menu item "\u2753 FAQ"');
await client.menuItems.update('c5jZf1KyTsCmRfzpTIwYAA', { position: 10 });

console.log('Manage schema menu items');

console.log('Update model schema menu item for model "\u2753 FAQ" (`faq`)');
await client.schemaMenuItems.update('XAz-FlSfTumPrm_vuq20lQ', {
position: 52,
});

console.log(
'Update block schema menu item for block model "\u2753 FAQ Block" (`faq_block`)',
);
await client.schemaMenuItems.update('PQtosMOrR_2nUjIwyGI5-w', {
position: 51,
});
}
2 changes: 1 addition & 1 deletion datocms-environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@
* @see docs/getting-started.md on how to use this file
* @see docs/decision-log/2023-10-24-datocms-env-file.md on why file is preferred over env vars
*/
export const datocmsEnvironment = 'deploy-restructure-grouping-block';
export const datocmsEnvironment = 'dev-sandbox-21-05';
export const datocmsBuildTriggerId = '34548';
2 changes: 2 additions & 0 deletions src/blocks/Blocks.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {
TabsBlockFragment,
AccordionBlockFragment,
ColumnBlockFragment,
FaqBlockFragment,
} from '~/lib/datocms/types';
import type { VariableBlockRecord } from '~/lib/datocms/schema';

Expand All @@ -42,5 +43,6 @@ export type AnyBlock = Omit<
| TabsBlockFragment
| AccordionBlockFragment
| ColumnBlockFragment
| FaqBlockFragment
), '__typename' // Allow for any __typename so that missing blocks can be reported on.
> & { __typename: string };
26 changes: 26 additions & 0 deletions src/blocks/FaqBlock/FaqBlock.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
import Blocks from '~/blocks/Blocks.astro';
import { Accordion, AccordionItem } from '~/components/Accordion';
import type { FaqBlockFragment } from '~/lib/datocms/types';

export interface Props {
block: FaqBlockFragment
}
const { block } = Astro.props;

const faqs = block.questionAndAnswers.filter((faq) => faq.questionAndAnswer);
---
{ block.groupTitle && <h2>{ block.groupTitle }</h2> }

<Accordion>
{ faqs.map((faq) => (
Comment on lines +13 to +16
<AccordionItem id={ faq.slug ?? undefined }>
<Fragment slot="heading">{ faq.questionAndAnswer.title }</Fragment>
<Fragment slot="body">
<Blocks blocks={ faq.questionAndAnswer.blocks } />
</Fragment>
</AccordionItem>
)) }
</Accordion>

<script src="./FaqBlock.client.ts"></script>
21 changes: 21 additions & 0 deletions src/blocks/FaqBlock/FaqBlock.client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
function openTargetedQuestion() {
const { hash } = window.location;
if (!hash) {
return;
}

let target: Element | null;
try {
target = document.querySelector(hash);
} catch {
return;
}

if (target instanceof HTMLDetailsElement) {
target.open = true;
target.scrollIntoView();
}
}

openTargetedQuestion();
window.addEventListener('hashchange', openTargetedQuestion);
Comment on lines +20 to +21
17 changes: 17 additions & 0 deletions src/blocks/FaqBlock/FaqBlock.fragment.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
#import '~/blocks/AccordionBlock/AccordionBlock.fragment.graphql'

fragment FaqBlock on FaqBlockRecord {
__typename
id
groupTitle
questionAndAnswers {
id
slug
questionAndAnswer {
__typename
... on AccordionItemRecord {
...AccordionItem
}
}
}
}
1 change: 1 addition & 0 deletions src/blocks/FaqBlock/FaqBlock.preview.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Renders reusable FAQ records as an accordion of questions and answers, with an optional group heading.
70 changes: 70 additions & 0 deletions src/blocks/FaqBlock/FaqBlock.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { renderToFragment } from '~/lib/renderer';
import { describe, expect, test } from 'vitest';
import FaqBlock, { type Props } from './FaqBlock.astro';

const ACCORDION_ITEM_RECORD = 'AccordionItemRecord' as const;
const TEXT_BLOCK_RECORD = 'TextBlockRecord' as const;

const textBlock = (value: string) => ({
__typename: TEXT_BLOCK_RECORD,
text: {
blocks: [],
inlineBlocks: [],
links: [],
value: {
schema: 'dast',
document: {
type: 'root',
children: [
{ type: 'paragraph', children: [{ type: 'span', value }] },
],
},
},
},
});

const faqItem = (slug: string, question: string, answer: string) => ({
id: slug,
slug,
questionAndAnswer: { __typename: ACCORDION_ITEM_RECORD, title: question, blocks: [textBlock(answer)] },
});

describe('FaqBlock', () => {
const renderBlock = () => renderToFragment<Props>(FaqBlock, {
props: {
block: {
__typename: 'FaqBlockRecord',
id: 'faq-block-1',
groupTitle: 'Frequently asked questions',
questionAndAnswers: [
faqItem('question-a', 'Question A', 'Answer A'),
faqItem('question-b', 'Question B', 'Answer B'),
],
},
},
});

test('renders the group title as a heading', async () => {
const fragment = await renderBlock();
expect(fragment.querySelector('h2')?.textContent).toBe('Frequently asked questions');
});

test('renders each linked question as an accordion item', async () => {
const fragment = await renderBlock();
const summaries = [...fragment.querySelectorAll('summary')].map(s => s.textContent?.trim());
expect(fragment.querySelectorAll('details').length).toBe(2);
expect(summaries?.[0]).toContain('Question A');
expect(summaries?.[1]).toContain('Question B');
});

test('renders the answer body of a linked question', async () => {
const fragment = await renderBlock();
expect(fragment.textContent).toContain('Answer A');
});

test('exposes each question by its slug for deep-linking', async () => {
const fragment = await renderBlock();
expect(fragment.querySelector('details#question-a')).toBeTruthy();
expect(fragment.querySelector('details#question-b')).toBeTruthy();
});
});
5 changes: 5 additions & 0 deletions src/blocks/FaqBlock/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# FAQ Block

**Renders a set of reusable FAQ records as an accordion of questions and answers.**

Each FAQ is a shared record in the `Faq` model, holding a single Accordion Item (its title is the question, its blocks are the answer). The FAQ Block links to the specific FAQ records you want and renders them as an accordion, so the same question can be reused across multiple pages. Use the `groupTitle` field to add a heading above the questions.
2 changes: 2 additions & 0 deletions src/blocks/blocksByTypename.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import VideoBlock from './VideoBlock/VideoBlock.astro';
import VideoEmbedBlock from './VideoEmbedBlock/VideoEmbedBlock.astro';
import SearchFormBlock from './SearchFormBlock/SearchFormBlock.astro';
import AccordionBlock from './AccordionBlock/AccordionBlock.astro';
import FaqBlock from './FaqBlock/FaqBlock.astro';
import StackBlock from './StackBlock/StackBlock.astro';
import TabsBlock from './TabsBlock/TabsBlock.astro';
import ColumnBlock from './ColumnBlock/ColumnBlock.astro';
Expand All @@ -34,6 +35,7 @@ export const blocksByTypename = {
VideoBlockRecord: VideoBlock,
VideoEmbedBlockRecord: VideoEmbedBlock,
AccordionBlockRecord: AccordionBlock,
FaqBlockRecord: FaqBlock,
StackBlockRecord: StackBlock,
TabsBlockRecord: TabsBlock,
SearchFormBlockRecord: SearchFormBlock,
Expand Down
Loading
Loading