diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index d03379131..847e5a103 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -14,7 +14,7 @@ Closes [insert issue #] - [ ] Yes - this PR contains breaking changes - Details ... -- [ ] No - this PR is backwards compatible +- [ ] No - this PR is backwards compatible with ALL of the following feature flags in this [doc](https://www.notion.so/opengov/Existing-feature-flags-518ad2cdc325420893a105e88c432be5) **Features**: diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml index bc7e2c975..ec7560df6 100644 --- a/.github/workflows/chromatic.yml +++ b/.github/workflows/chromatic.yml @@ -62,7 +62,8 @@ jobs: with: fetch-depth: 0 # 👈 Required to retrieve git history - - name: Check for changes + - name: Check for changes (push) + if: ${{ github.event_name == 'push' }} uses: dorny/paths-filter@v2 id: filter with: @@ -72,9 +73,11 @@ jobs: - 'src/layouts/**' - 'src/theme/**' - 'src/styles/**' + - 'package.json' + - 'package-lock.json' - name: Set environment variable to run Chromatic build - if: ${{ (github.event_name == 'push' || (steps.check.outputs.triggered == 'true' && github.event_name == 'issue_comment' && github.event.issue.pull_request)) && steps.filter.outputs.frontend == 'true' }} + if: ${{ ((github.event_name == 'push' && steps.filter.outputs.frontend == 'true') || (steps.check.outputs.triggered == 'true' && github.event_name == 'issue_comment' && github.event.issue.pull_request)) }} run: echo "ISOMER_RUN_CHROMATIC_BUILD=true" >> $GITHUB_ENV # This extra step is not in the original chromatic workflow. diff --git a/CHANGELOG.md b/CHANGELOG.md index 37006b381..11afc7f70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,18 @@ All notable changes to this project will be documented in this file. Dates are d Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). +#### [v0.45.0](https://github.com/isomerpages/isomercms-frontend/compare/v0.44.0...v0.45.0) + +- Feat/announcement block [`#1497`](https://github.com/isomerpages/isomercms-frontend/pull/1497) +- feat: introduce new help overlay for add section button [`#1515`](https://github.com/isomerpages/isomercms-frontend/pull/1515) +- feat(template): add ffs as a manual check-in [`#1469`](https://github.com/isomerpages/isomercms-frontend/pull/1469) +- Release/0.44.0 [`#1511`](https://github.com/isomerpages/isomercms-frontend/pull/1511) +- fix(chromatic): only do path checking on push [`#1513`](https://github.com/isomerpages/isomercms-frontend/pull/1513) + #### [v0.44.0](https://github.com/isomerpages/isomercms-frontend/compare/v0.43.0...v0.44.0) +> 20 September 2023 + - feat(flags): add feature flag [`#1507`](https://github.com/isomerpages/isomercms-frontend/pull/1507) - feat(homepage): add floating variant [`#1498`](https://github.com/isomerpages/isomercms-frontend/pull/1498) - feat(heroimageonlylayout): add dropdown [`#1494`](https://github.com/isomerpages/isomercms-frontend/pull/1494) @@ -28,15 +38,21 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - fix(herobody): solves empty highlight deafult issue [`#1489`](https://github.com/isomerpages/isomercms-frontend/pull/1489) - fix(edithomepage): spread properly [`#1487`](https://github.com/isomerpages/isomercms-frontend/pull/1487) - Release/0.42.0 (develop) [`#1481`](https://github.com/isomerpages/isomercms-frontend/pull/1481) +- fix(editable): hover and focus states for title text [`#1484`](https://github.com/isomerpages/isomercms-frontend/pull/1484) +- Fix/style nits [`#1483`](https://github.com/isomerpages/isomercms-frontend/pull/1483) +- fix: styling [`#1482`](https://github.com/isomerpages/isomercms-frontend/pull/1482) +- fix(editable): change drag handle to be on top part only [`#1475`](https://github.com/isomerpages/isomercms-frontend/pull/1475) +- feat(editable): introduce new nested card variant [`#1478`](https://github.com/isomerpages/isomercms-frontend/pull/1478) +- fix(homepage): various styling fixes [`#1477`](https://github.com/isomerpages/isomercms-frontend/pull/1477) +- Fix/edit nav nits [`#1476`](https://github.com/isomerpages/isomercms-frontend/pull/1476) +- fix(edithomepage): spread properly [`#1474`](https://github.com/isomerpages/isomercms-frontend/pull/1474) +- Chore/fix title text [`#1472`](https://github.com/isomerpages/isomercms-frontend/pull/1472) +- Chore/fix edit nav bar styles [`#1466`](https://github.com/isomerpages/isomercms-frontend/pull/1466) #### [v0.42.0](https://github.com/isomerpages/isomercms-frontend/compare/v0.41.0...v0.42.0) > 7 September 2023 -- fix(editable): hover and focus states for title text [`#1484`](https://github.com/isomerpages/isomercms-frontend/pull/1484) -- Fix/style nits [`#1483`](https://github.com/isomerpages/isomercms-frontend/pull/1483) -- fix: styling [`#1482`](https://github.com/isomerpages/isomercms-frontend/pull/1482) -- fix(editable): change drag handle to be on top part only [`#1475`](https://github.com/isomerpages/isomercms-frontend/pull/1475) - feat(editable): introduce new nested card variant [`#1478`](https://github.com/isomerpages/isomercms-frontend/pull/1478) - fix(homepage): various styling fixes [`#1477`](https://github.com/isomerpages/isomercms-frontend/pull/1477) - Fix/edit nav nits [`#1476`](https://github.com/isomerpages/isomercms-frontend/pull/1476) diff --git a/package-lock.json b/package-lock.json index 008d28053..702859c13 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "isomercms-frontend", - "version": "0.44.0", + "version": "0.45.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "isomercms-frontend", - "version": "0.44.0", + "version": "0.45.0", "hasInstallScript": true, "dependencies": { "@braintree/sanitize-url": "^6.0.1", @@ -49,6 +49,7 @@ "libphonenumber-js": "^1.9.48", "lodash": "^4.17.21", "marked": "^4.0.12", + "moment": "^2.29.4", "moment-timezone": "^0.5.35", "postcss": "^8.4.21", "postcss-loader": "^7.2.4", @@ -27077,7 +27078,8 @@ }, "node_modules/moment": { "version": "2.29.4", - "license": "MIT", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", "engines": { "node": "*" } diff --git a/package.json b/package.json index 0ebd40ef1..bfa85cbba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "isomercms-frontend", - "version": "0.44.0", + "version": "0.45.0", "private": true, "engines": { "node": ">=16.0.0" @@ -46,6 +46,7 @@ "libphonenumber-js": "^1.9.48", "lodash": "^4.17.21", "marked": "^4.0.12", + "moment": "^2.29.4", "moment-timezone": "^0.5.35", "postcss": "^8.4.21", "postcss-loader": "^7.2.4", diff --git a/src/assets/images/HomepageAnnouncementsSampleImage.tsx b/src/assets/images/HomepageAnnouncementsSampleImage.tsx new file mode 100644 index 000000000..c68d60906 --- /dev/null +++ b/src/assets/images/HomepageAnnouncementsSampleImage.tsx @@ -0,0 +1,114 @@ +export const HomepageAnnouncementsSampleImage = ( + props: React.SVGProps +): JSX.Element => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/src/assets/images/HomepageTextCardsSampleImage.tsx b/src/assets/images/HomepageTextCardsSampleImage.tsx new file mode 100644 index 000000000..def7fe9ec --- /dev/null +++ b/src/assets/images/HomepageTextCardsSampleImage.tsx @@ -0,0 +1,274 @@ +export const HomepageTextCardsSampleImage = ( + props: React.SVGProps +): JSX.Element => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/src/assets/images/index.ts b/src/assets/images/index.ts index 3989cd748..00aa40dce 100644 --- a/src/assets/images/index.ts +++ b/src/assets/images/index.ts @@ -17,3 +17,5 @@ export * from "./SiteLaunchSuccessImage" export * from "./SiteLaunchFailureImage" export * from "./SiteLaunchPendingImage" export * from "./NotFoundSubmarineImage" +export * from "./HomepageAnnouncementsSampleImage" +export * from "./HomepageTextCardsSampleImage" diff --git a/src/components/Editable/AddSectionButton.stories.tsx b/src/components/Editable/AddSectionButton.stories.tsx new file mode 100644 index 000000000..6016372a9 --- /dev/null +++ b/src/components/Editable/AddSectionButton.stories.tsx @@ -0,0 +1,108 @@ +import { Box, Center } from "@chakra-ui/react" +import type { Meta, StoryFn } from "@storybook/react" + +import { + HomepageAnnouncementsSampleImage, + HomepageTextCardsSampleImage, +} from "assets/images" + +import { AddSectionButton } from "./AddSectionButton" + +const addSectionButtonMeta = { + title: "Components/AddSectionButton", + component: AddSectionButton, +} as Meta + +interface AddSectionButtonOptions { + title: string + subtitle: string + overlayTitle?: string + overlayDescription?: string + overlayImage?: JSX.Element +} + +interface AddSectionButtonTemplateArgs { + buttonText: string + options: AddSectionButtonOptions[] +} + +const Template: StoryFn = ({ + buttonText, + options, +}: AddSectionButtonTemplateArgs) => { + return ( + +
+ This space is for content. +
+ + + {options.map((option) => { + if (option.overlayTitle && option.overlayDescription) { + return ( + + + + ) + } + + return ( + + ) + })} + + +
+ ) +} + +export const Default = Template.bind({}) +Default.args = { + buttonText: "Add section", + options: [ + { + title: "Infopic", + subtitle: "Add an image and text", + }, + { + title: "Text cards", + subtitle: "Add up to 4 clickable cards with text", + overlayTitle: "Text cards", + overlayDescription: + "Add clickable cards with bite-sized information to your homepage. You can link any page or external URL, such as blog posts, articles, and more.", + overlayImage: , + }, + { + title: "Info-columns", + subtitle: "Add snippets of text in 2- or 3-column layouts", + overlayTitle: "Info-Columns", + overlayDescription: + "Add bite-sized snippets of text in a multi-column layout. These texts aren’t clickable. Perfect for showing informative text that describes your organisation.", + }, + { + title: "Announcements", + subtitle: "Add a list of announcements with dates", + overlayTitle: "Announcements", + overlayDescription: + "Make exciting news from your organisation stand out by adding a list of announcements with dates on your homepage.", + overlayImage: , + }, + ], +} + +export default addSectionButtonMeta diff --git a/src/layouts/components/Editable/AddSectionButton.tsx b/src/components/Editable/AddSectionButton.tsx similarity index 51% rename from src/layouts/components/Editable/AddSectionButton.tsx rename to src/components/Editable/AddSectionButton.tsx index 04c7e315e..d572eed76 100644 --- a/src/layouts/components/Editable/AddSectionButton.tsx +++ b/src/components/Editable/AddSectionButton.tsx @@ -1,5 +1,20 @@ -import { HStack, Icon, MenuItemProps, Text } from "@chakra-ui/react" +import { + Box, + HStack, + Icon, + MenuItemProps, + Popover, + PopoverArrow, + PopoverBody, + PopoverContent, + PopoverProps, + PopoverTrigger, + Portal, + Text, + VStack, +} from "@chakra-ui/react" import { Menu, MenuButtonProps } from "@opengovsg/design-system-react" +import { PropsWithChildren } from "react" import { BiPlus } from "react-icons/bi" type AddSectionButtonProps = MenuButtonProps & { @@ -54,5 +69,48 @@ const AddSectionButtonOption = ({ ) } +export interface HelpOverlayProps + extends Omit, + PropsWithChildren { + title: string + description: string + image?: JSX.Element +} +const HelpOverlay = ({ + title, + description, + image, + children, + ...rest +}: HelpOverlayProps) => { + return ( + + + {children} + + {/* Portal is needed to avoid PopoverArrow from jumping around */} + + + + + + {image && {image}} + {title} + {description} + + + + + + ) +} + AddSectionButton.Option = AddSectionButtonOption AddSectionButton.List = Menu.List +AddSectionButton.HelpOverlay = HelpOverlay diff --git a/src/layouts/components/Editable/Editable.tsx b/src/components/Editable/Editable.tsx similarity index 99% rename from src/layouts/components/Editable/Editable.tsx rename to src/components/Editable/Editable.tsx index faa2a913e..c216f8483 100644 --- a/src/layouts/components/Editable/Editable.tsx +++ b/src/components/Editable/Editable.tsx @@ -162,7 +162,11 @@ const EditableSidebar = ({ ) } -type HomepageDroppableZone = "dropdownelem" | "leftPane" | "highlight" +type HomepageDroppableZone = + | "dropdownelem" + | "leftPane" + | "highlight" + | "announcement" type ContactUsDroppableZone = | "locations" | "contacts" diff --git a/src/layouts/components/Editable/index.ts b/src/components/Editable/index.ts similarity index 100% rename from src/layouts/components/Editable/index.ts rename to src/components/Editable/index.ts diff --git a/src/components/PageSettingsModal/PageSettingsSchema.jsx b/src/components/PageSettingsModal/PageSettingsSchema.jsx index 08c9dab1d..00e966617 100644 --- a/src/components/PageSettingsModal/PageSettingsSchema.jsx +++ b/src/components/PageSettingsModal/PageSettingsSchema.jsx @@ -5,7 +5,7 @@ import { permalinkRegexTest, specialCharactersRegexTest, jekyllFirstCharacterRegexTest, - dateRegexTest, + resourceDateRegexTest, PAGE_SETTINGS_PERMALINK_MIN_LENGTH, PAGE_SETTINGS_PERMALINK_MAX_LENGTH, PAGE_SETTINGS_TITLE_MIN_LENGTH, @@ -71,7 +71,10 @@ export const PageSettingsSchema = (existingTitlesArray = []) => layout ? schema .required("Date is required") - .matches(dateRegexTest, "Date must be formatted as YYYY-MM-DD") + .matches( + resourceDateRegexTest, + "Date must be formatted as YYYY-MM-DD" + ) .test( "Date cannot be in the future", "Date cannot be in the future", diff --git a/src/hooks/useDrag.tsx b/src/hooks/useDrag.tsx index 974478ebc..9194a971d 100644 --- a/src/hooks/useDrag.tsx +++ b/src/hooks/useDrag.tsx @@ -2,6 +2,8 @@ import { DropResult } from "@hello-pangea/dnd" import update from "immutability-helper" import _ from "lodash" +import { ANNOUNCEMENT_BLOCK } from "layouts/EditHomepage/constants" + import { EditorHeroDropdownSection, EditorHeroHighlightsSection, @@ -9,6 +11,9 @@ import { EditorHomepageState, HeroFrontmatterSection, PossibleEditorSections, + EditorHomepageFrontmatterSection, + AnnouncementsFrontmatterSection, + AnnouncementOption, } from "types/homepage" const updatePositions = ( @@ -33,6 +38,12 @@ const createElement = (section: T[], elem: T): T[] => { }) } +const createElementFromTop = (section: T[], elem: T): T[] => { + return update(section, { + $unshift: [elem], + }) +} + const deleteElement = (section: T[], indexToDelete: number): T[] => { return update(section, { $splice: [[indexToDelete, 1]], @@ -54,6 +65,32 @@ const updateEditorSection = ( errors: { ...homepageState.errors, sections: newSectionErrors }, }) +const updateAnnouncementSection = ( + homepageState: EditorHomepageState, + newDisplayAnnouncementItems: unknown[], + newAnnouncementOptions: unknown[], + newAnnouncementErrors: unknown[], + announcementsIndex: number +): EditorHomepageState => { + return { + ...homepageState, + displayAnnouncementItems: newDisplayAnnouncementItems, + frontMatter: { + ...homepageState.frontMatter, + sections: _.set( + // NOTE: Deep clone here to avoid mutation + _.cloneDeep(homepageState.frontMatter.sections), + [announcementsIndex, ANNOUNCEMENT_BLOCK.id, "announcement_items"], + newAnnouncementOptions + ), + }, + errors: { + ...homepageState.errors, + announcementItems: newAnnouncementErrors, + }, + } +} + const updateDropdownSection = ( homepageState: EditorHomepageState, newDisplayDropdownElems: unknown[], @@ -120,6 +157,7 @@ const updateHomepageState = ( displaySections, displayDropdownElems, displayHighlights, + displayAnnouncementItems, } = homepageState // If the user dropped the draggable to no known droppable @@ -241,6 +279,61 @@ const updateHomepageState = ( newHighlightErrors ) } + case "announcement": { + const doesAnnouncementKeyExist = !_.isEmpty( + frontMatter.sections.find((section) => + EditorHomepageFrontmatterSection.isAnnouncements(section) + ) + ) + if (!doesAnnouncementKeyExist) { + // should not reach here, but defensively return the original state + return homepageState + } + + const announcementsIndex = frontMatter.sections.findIndex((section) => + EditorHomepageFrontmatterSection.isAnnouncements(section) + ) + const draggedElem = (frontMatter.sections[ + announcementsIndex + // safe to assert as check is done above + ] as AnnouncementsFrontmatterSection).announcements.announcement_items[ + source.index + ] + + const newAnnouncementsOptions = updatePositions( + (frontMatter.sections[ + announcementsIndex + // safe to assert as check is done above + ] as AnnouncementsFrontmatterSection).announcements.announcement_items, + source.index, + destination.index, + draggedElem + ) + + const draggedError = errors.announcementItems[source.index] + const newAnnouncementErrors = updatePositions( + errors.announcementItems, + source.index, + destination.index, + draggedError + ) + const displayBool = displayAnnouncementItems[source.index] + const newDisplayAnnouncementItems = updatePositions( + displayAnnouncementItems, + source.index, + destination.index, + displayBool + ) + + return updateAnnouncementSection( + homepageState, + newDisplayAnnouncementItems, + newAnnouncementsOptions, + newAnnouncementErrors, + announcementsIndex + ) + } + default: return homepageState } @@ -260,6 +353,7 @@ export const onCreate = ( displaySections, displayDropdownElems, displayHighlights, + displayAnnouncementItems, } = homepageState switch (elemType) { @@ -332,6 +426,50 @@ export const onCreate = ( return updateHighlightsSection(homepageState, [true], [val], [err]) } + case "announcement": { + const announcementKeyExist = !_.isEmpty( + frontMatter.sections.find((section) => + EditorHomepageFrontmatterSection.isAnnouncements(section) + ) + ) + if (!announcementKeyExist) { + // should not reach here, but defensively return the original state + return homepageState + } + + const announcementsIndex = frontMatter.sections.findIndex((section) => + EditorHomepageFrontmatterSection.isAnnouncements(section) + ) + const announcementBlockSection: AnnouncementsFrontmatterSection = frontMatter + .sections[announcementsIndex] as AnnouncementsFrontmatterSection + + const announcements = createElementFromTop( + announcementBlockSection.announcements.announcement_items, + val as AnnouncementOption + ) + + const resetDisplaySections = _.fill( + Array(displayAnnouncementItems.length), + false + ) + const newDisplayAnnouncementItems = createElementFromTop( + resetDisplaySections, + true + ) + + const newAnnouncementErrors = createElementFromTop( + errors.announcementItems, + err + ) + + return updateAnnouncementSection( + homepageState, + newDisplayAnnouncementItems, + announcements, + newAnnouncementErrors, + announcementsIndex + ) + } default: return homepageState } @@ -348,6 +486,7 @@ export const onDelete = ( displaySections, displayDropdownElems, displayHighlights, + displayAnnouncementItems, } = homepageState switch (elemType) { @@ -406,6 +545,45 @@ export const onDelete = ( newHighlightErrors ) } + case "announcement": { + const announcementKeyExist = !_.isEmpty( + frontMatter.sections.find((section) => + EditorHomepageFrontmatterSection.isAnnouncements(section) + ) + ) + if (!announcementKeyExist) { + // should not reach here, but defensively return the original state + return homepageState + } + + const announcementsIndex = frontMatter.sections.findIndex((section) => + EditorHomepageFrontmatterSection.isAnnouncements(section) + ) + const announcementsSection: AnnouncementsFrontmatterSection = frontMatter + .sections[announcementsIndex] as AnnouncementsFrontmatterSection + + const newAnnouncementOptions = deleteElement( + announcementsSection.announcements.announcement_items, + indexToDelete + ) + const newAnnouncementErrors = deleteElement( + errors.announcementItems, + indexToDelete + ) + + const newDisplayAnnouncements = deleteElement( + displayAnnouncementItems, + indexToDelete + ) + + return updateAnnouncementSection( + homepageState, + newDisplayAnnouncements, + newAnnouncementOptions, + newAnnouncementErrors, + announcementsIndex + ) + } default: return homepageState } diff --git a/src/layouts/EditContactUs.jsx b/src/layouts/EditContactUs.jsx index 074855655..0af40bcfd 100644 --- a/src/layouts/EditContactUs.jsx +++ b/src/layouts/EditContactUs.jsx @@ -14,6 +14,7 @@ import _ from "lodash" import PropTypes from "prop-types" import { createRef, useEffect, useState } from "react" +import { Editable } from "components/Editable" import { Footer } from "components/Footer" import Header from "components/Header" import { LoadingButton } from "components/LoadingButton" @@ -46,7 +47,6 @@ import { DEFAULT_RETRY_MSG, isEmpty } from "utils" import { CardsSection } from "./components/ContactUs/CardsSection" import { GeneralInfoSection } from "./components/ContactUs/GeneralInfoSection" -import { Editable } from "./components/Editable" /* eslint-disable react/no-array-index-key */ diff --git a/src/layouts/EditHomepage/EditHomepage.jsx b/src/layouts/EditHomepage/EditHomepage.jsx index cf7a361c0..4c780ff44 100644 --- a/src/layouts/EditHomepage/EditHomepage.jsx +++ b/src/layouts/EditHomepage/EditHomepage.jsx @@ -7,18 +7,25 @@ import { VStack, Divider, } from "@chakra-ui/react" +import { useFeatureIsOn } from "@growthbook/growthbook-react" import { DragDropContext } from "@hello-pangea/dnd" import { Button, Tag } from "@opengovsg/design-system-react" import update from "immutability-helper" import _ from "lodash" import { useEffect, createRef, useState } from "react" +import { CustomiseSectionsHeader, Editable } from "components/Editable" +import { AddSectionButton } from "components/Editable/AddSectionButton" import { Footer } from "components/Footer" import Header from "components/Header" import { LoadingButton } from "components/LoadingButton" import { WarningModal } from "components/WarningModal" +import { FEATURE_FLAGS } from "constants/featureFlags" + // Import hooks +import { EditableContextProvider } from "contexts/EditableContext" + import { useGetHomepageHook } from "hooks/homepageHooks" import { useUpdateHomepageHook } from "hooks/homepageHooks/useUpdateHomepageHook" import { useAfterFirstLoad } from "hooks/useAfterFirstLoad" @@ -31,15 +38,16 @@ import { validateSections, validateHighlights, validateDropdownElems, + validateAnnouncementItems, } from "utils/validators" import { HomepageStartEditingImage } from "assets" +import { EditorHomepageFrontmatterSection } from "types/homepage" import { DEFAULT_RETRY_MSG } from "utils" -import { EditableContextProvider } from "../../contexts/EditableContext" import { useDrag, onCreate, onDelete } from "../../hooks/useDrag" -import { CustomiseSectionsHeader, Editable } from "../components/Editable" -import { AddSectionButton } from "../components/Editable/AddSectionButton" +import { AnnouncementBody } from "../components/Homepage/AnnouncementBody" +import { AnnouncementSection } from "../components/Homepage/AnnouncementSection" import { HeroBody } from "../components/Homepage/HeroBody" import { HeroDropdownSection } from "../components/Homepage/HeroDropdownSection" import { HeroHighlightSection } from "../components/Homepage/HeroHighlightSection" @@ -54,6 +62,8 @@ import { INFOPIC_SECTION, KEY_HIGHLIGHT_SECTION, RESOURCES_SECTION, + ANNOUNCEMENT_BLOCK, + getDefaultAnnouncementSection, } from "./constants" import { HomepagePreview } from "./HomepagePreview" import { getErrorValues } from "./utils" @@ -94,8 +104,14 @@ const getHasErrors = (errors) => { const hasHighlightErrors = getHasError(errors.highlights) const hasDropdownElemErrors = getHasError(errors.dropdownElems) + const hasAnnouncementErrors = getHasError(errors.announcementItems) - return hasSectionErrors || hasHighlightErrors || hasDropdownElemErrors + return ( + hasSectionErrors || + hasHighlightErrors || + hasDropdownElemErrors || + hasAnnouncementErrors + ) } // Constants @@ -119,6 +135,11 @@ const enumSection = (type, isError) => { ? { infopic: getErrorValues(INFOPIC_SECTION) } : { infopic: INFOPIC_SECTION } + case "announcements": + return isError + ? { announcements: getErrorValues(ANNOUNCEMENT_BLOCK) } + : { announcements: ANNOUNCEMENT_BLOCK } + default: return isError ? { infobar: getErrorValues(INFOBAR_SECTION) } @@ -150,12 +171,14 @@ const EditHomepage = ({ match }) => { }) const [sha, setSha] = useState(null) const [displaySections, setDisplaySections] = useState([]) + const [displayAnnouncementItems, setDisplayAnnouncementItems] = useState([]) const [displayHighlights, setDisplayHighlights] = useState([]) const [displayDropdownElems, setDisplayDropdownElems] = useState([]) const [errors, setErrors] = useState({ sections: [], highlights: [], dropdownElems: [], + announcementItems: [], }) const [itemPendingForDelete, setItemPendingForDelete] = useState({ id: "", @@ -172,6 +195,7 @@ const EditHomepage = ({ match }) => { displayDropdownElems, displayHighlights, displaySections, + displayAnnouncementItems, } const onDrag = useDrag(homepageState) const setHomepageState = ({ @@ -180,12 +204,14 @@ const EditHomepage = ({ match }) => { displayDropdownElems, displayHighlights, displaySections, + displayAnnouncementItems, }) => { setDisplaySections(displaySections) setFrontMatter(frontMatter) setErrors(errors) setDisplayDropdownElems(displayDropdownElems) setDisplayHighlights(displayHighlights) + setDisplayAnnouncementItems(displayAnnouncementItems) } const heroSection = frontMatter.sections.filter((section) => !!section.hero) @@ -205,10 +231,10 @@ const EditHomepage = ({ match }) => { } try { - const { + let { content: { frontMatter }, - sha, } = homepageData + const { sha } = homepageData // Set displaySections const displaySections = [] let displayHighlights = [] @@ -216,6 +242,7 @@ const EditHomepage = ({ match }) => { const sectionsErrors = [] let dropdownElemsErrors = [] let highlightsErrors = [] + let announcementItemErrors = [] const scrollRefs = [] frontMatter.sections.forEach((section) => { scrollRefs.push(createRef()) @@ -267,6 +294,32 @@ const EditHomepage = ({ match }) => { sectionsErrors.push({ infopic: getErrorValues(INFOPIC_SECTION) }) } + if (section.announcements) { + sectionsErrors.push({ + announcements: getErrorValues(ANNOUNCEMENT_BLOCK), + }) + announcementItemErrors = _.map( + section.announcements.announcement_items, + () => getErrorValues(getDefaultAnnouncementSection()) + ) + if (!section.announcements.announcement_items) { + // define an empty array to announcement_items to prevent error + frontMatter = update(frontMatter, { + sections: { + [frontMatter.sections.findIndex((section) => + EditorHomepageFrontmatterSection.isAnnouncements(section) + )]: { + announcements: { + announcement_items: { + $set: [], + }, + }, + }, + }, + }) + } + } + // Minimize all sections by default displaySections.push(false) }) @@ -276,6 +329,7 @@ const EditHomepage = ({ match }) => { sections: sectionsErrors, highlights: highlightsErrors, dropdownElems: dropdownElemsErrors, + announcementItems: announcementItemErrors, } setFrontMatter(frontMatter) @@ -332,7 +386,7 @@ const EditHomepage = ({ match }) => { // sectionIndex is the index of the section array in the frontMatter const sectionIndex = parseInt(idArray[1], RADIX_PARSE_INT) - const sectionType = idArray[2] // e.g. "hero" or "infobar" or "resources" + const sectionType = idArray[2] // e.g. "hero" or "infobar" or "resources" or "announcements" const field = idArray[3] // e.g. "title" or "subtitle" const newSections = update(sections, { @@ -441,6 +495,68 @@ const EditHomepage = ({ match }) => { scrollTo(scrollRefs[0]) break } + case "announcement": { + // The field that changed belongs to an announcement item + const { sections } = frontMatter + + const announcementsIndex = frontMatter.sections.findIndex((section) => + EditorHomepageFrontmatterSection.isAnnouncements(section) + ) + // announcementIndex is the index of the announcement items array + const announcementItemsIndex = parseInt(idArray[1], RADIX_PARSE_INT) + const field = idArray[2] // e.g. "title" or "url" + + const newSections = update(sections, { + [announcementsIndex]: { + announcements: { + announcement_items: { + [announcementItemsIndex]: { + [field]: { + $set: value, + }, + }, + }, + }, + }, + }) + + const newErrors = update(errors, { + announcementItems: { + [announcementItemsIndex]: { + $set: validateAnnouncementItems( + errors.announcementItems[announcementItemsIndex], + field, + value + ), + }, + }, + }) + + // Additional validation that depends on other fields + const isLinkTextFilled = !!newSections[announcementsIndex] + .announcements.announcement_items[announcementItemsIndex].link_text + const isLinkUrlFilled = !!newSections[announcementsIndex] + .announcements.announcement_items[announcementItemsIndex].link_url + + const isLinkUrlError = isLinkTextFilled && !isLinkUrlFilled + const isLinkUrlOrTextChanged = + field === "link_text" || field === "link_url" + if (isLinkUrlOrTextChanged) { + newErrors.announcementItems[ + announcementItemsIndex + ].link_url = isLinkUrlError + ? "Please specify a URL for your link" + : "" + } + + setFrontMatter({ + ...frontMatter, + sections: newSections, + }) + setErrors(newErrors) + scrollTo(scrollRefs[announcementsIndex]) + break + } case "dropdownelem": { // The field that changed is a dropdown element (i.e. dropdownelem) const { sections } = frontMatter @@ -550,6 +666,18 @@ const EditHomepage = ({ match }) => { setHomepageState(updatedHomepageState) setScrollRefs(newScrollRefs) + + // Edge case for announcements where we need to + // create a default announcement item + if (val.announcements) { + const updatedAnnouncementState = onCreate( + updatedHomepageState, + "announcement", + getDefaultAnnouncementSection(), + getErrorValues(getDefaultAnnouncementSection()) + ) + setHomepageState(updatedAnnouncementState) + } break } case "dropdownelem": { @@ -582,6 +710,18 @@ const EditHomepage = ({ match }) => { setDisplayHighlights(displayHighlights) break } + case "announcement": { + const val = getDefaultAnnouncementSection() + const err = getErrorValues(getDefaultAnnouncementSection()) + const updatedHomepageState = onCreate( + homepageState, + elemType, + val, + err + ) + setHomepageState(updatedHomepageState) + break + } default: } } catch (err) { @@ -787,10 +927,26 @@ const EditHomepage = ({ match }) => { }) setDisplaySections(newDisplaySections) - scrollTo(scrollRefs[index]) break } + case "announcement": { + const announcementsIndex = frontMatter.sections.findIndex((section) => + EditorHomepageFrontmatterSection.isAnnouncements(section) + ) + const resetAnnouncementSections = _.fill( + Array(displayAnnouncementItems.length), + false + ) + resetAnnouncementSections[index] = !displayAnnouncementItems[index] + const newDisplayAnnouncements = update(displayAnnouncementItems, { + $set: resetAnnouncementSections, + }) + + scrollTo(scrollRefs[announcementsIndex]) + setDisplayAnnouncementItems(newDisplayAnnouncements) + break + } case "highlight": { const resetHighlightSections = _.fill( Array(displayHighlights.length), @@ -861,7 +1017,7 @@ const EditHomepage = ({ match }) => { } } - // NOTE: sectionType is one of `resources`, `infopic` or `infobar` + // NOTE: sectionType is one of `announcements, `resources`, `infopic` or `infobar` const onClick = (sectionType) => { createHandler({ target: { @@ -871,6 +1027,7 @@ const EditHomepage = ({ match }) => { }) } + const showNewLayouts = useFeatureIsOn(FEATURE_FLAGS.HOMEPAGE_TEMPLATES) return ( <> { setItemPendingForDelete({ id: null, type: "" }) onClose() }} - displayTitle={`Delete ${itemPendingForDelete.type} section`} + displayTitle={`Delete ${itemPendingForDelete.type}`} displayText={ Are you sure you want to delete {itemPendingForDelete.type}? @@ -1082,6 +1239,56 @@ const EditHomepage = ({ match }) => { /> )} + + {showNewLayouts && + section.announcements && + /** + * Somehow, the errors are undefined for 2 renders, not sure + * of the core reason. To avoid the CMS panel from crashing, + * wrapping this around a check for defined errors + */ + errors.sections[sectionIndex] + .announcements && ( + + Announcement + + } + title={ + section.announcements.title || + "New Announcement" + } + isInvalid={ + _.some( + errors.sections[sectionIndex] + .announcements + ) || + getHasError(errors.announcementItems) + } + > + + + + + )} ) )} @@ -1091,9 +1298,9 @@ const EditHomepage = ({ match }) => { - {/* NOTE: Set the padding here - - We cannot let the button be part of the `Draggable` - as otherwise, when dragging, + {/* NOTE: Set the padding here - + We cannot let the button be part of the `Draggable` + as otherwise, when dragging, the component will appear over the button */} @@ -1109,6 +1316,20 @@ const EditHomepage = ({ match }) => { subtitle={INFOBAR_SECTION.subtitle} onClick={() => onClick(INFOBAR_SECTION.id)} /> + {/* NOTE: Check if the sections contain any `announcements` + and if it does, prevent creation of another `resources` section + */} + {showNewLayouts && + !frontMatter.sections.some( + ({ announcements }) => !!announcements + ) && ( + onClick(ANNOUNCEMENT_BLOCK.id)} + /> + )} + {/* NOTE: Check if the sections contain any `resources` and if it does, prevent creation of another `resources` section */} diff --git a/src/layouts/EditHomepage/constants.ts b/src/layouts/EditHomepage/constants.ts index 59a7c1e34..4b82a8cf3 100644 --- a/src/layouts/EditHomepage/constants.ts +++ b/src/layouts/EditHomepage/constants.ts @@ -1,9 +1,42 @@ +import moment from "moment" + export const RESOURCES_SECTION = { title: "Resources", subtitle: "Add a preview and link to your Resource Room", id: "resources", } as const +export type AnnouncementSectionType = { + readonly title: string + readonly date: string + readonly announcement: string + readonly link_text: string + readonly link_url: string +} + +export const getDefaultAnnouncementSection = (): AnnouncementSectionType => { + return { + title: "New Announcement", + date: moment( + new Date() + .toLocaleString("en-SG", { + timeZone: "Asia/Singapore", + }) + .slice(0, "dd/mm/yyyy".length), + "DD/MM/YYYY" + ).format("DD MMMM YYYY"), + announcement: "Announcement content", + link_text: "", + link_url: "", + } +} +export const ANNOUNCEMENT_BLOCK = { + title: "Announcements", + id: "announcements", + subtitle: "Add a list of announcements with dates", + announcement_items: [] as AnnouncementSectionType[], +} as const + export const INFOBAR_SECTION = { title: "Infobar", subtitle: "Add informational text", diff --git a/src/layouts/EditNavBar.jsx b/src/layouts/EditNavBar.jsx index b541d9d66..6480a3ed6 100644 --- a/src/layouts/EditNavBar.jsx +++ b/src/layouts/EditNavBar.jsx @@ -7,6 +7,8 @@ import PropTypes from "prop-types" import { useEffect, useState } from "react" import { useQuery } from "react-query" +import { Editable } from "components/Editable" +import { AddSectionButton } from "components/Editable/AddSectionButton" import { Footer } from "components/Footer" import Header from "components/Header" import { LoadingButton } from "components/LoadingButton" @@ -31,8 +33,6 @@ import { validateLink } from "utils/validators" import { getEditNavBarData } from "api" import { DEFAULT_RETRY_MSG, deslugifyDirectory, isEmpty } from "utils" -import { Editable } from "./components/Editable" -import { AddSectionButton } from "./components/Editable/AddSectionButton" import { FolderMenuBody } from "./components/NavBar/FolderMenuBody" import { GroupMenuBody } from "./components/NavBar/GroupMenuBody" import { PageMenuBody } from "./components/NavBar/PageMenuBody" @@ -766,7 +766,7 @@ const EditNavBar = ({ match }) => { - {/* NOTE: Check if the site contains any collections in `options` + {/* NOTE: Check if the site contains any collections in `options` if it does not, prevent creation of a `folder` section */} {options && options.length > 0 && ( @@ -807,7 +807,7 @@ const EditNavBar = ({ match }) => { }) }} /> - {/* NOTE: Check if the site does not contain a resource room or any sections contain `resource_room` + {/* NOTE: Check if the site does not contain a resource room or any sections contain `resource_room` If either condition is fulfilled, prevent creation of a `resource_room` section */} {hasResourceRoom && !links.some( diff --git a/src/layouts/components/ContactUs/CardsSection.tsx b/src/layouts/components/ContactUs/CardsSection.tsx index b03b6e53e..06cd17446 100644 --- a/src/layouts/components/ContactUs/CardsSection.tsx +++ b/src/layouts/components/ContactUs/CardsSection.tsx @@ -1,10 +1,10 @@ import { Text, VStack } from "@chakra-ui/react" import { DragDropContext } from "@hello-pangea/dnd" -import { useEditableContext } from "contexts/EditableContext" +import { Editable } from "components/Editable" +import { AddSectionButton } from "components/Editable/AddSectionButton" -import { Editable } from "../Editable" -import { AddSectionButton } from "../Editable/AddSectionButton" +import { useEditableContext } from "contexts/EditableContext" import { ContactCard, ContactCardFrontMatter } from "./ContactCard" import { LocationCard, LocationCardFrontMatter } from "./LocationCard" diff --git a/src/layouts/components/ContactUs/ContactCard.tsx b/src/layouts/components/ContactUs/ContactCard.tsx index 4084da403..a5a7dc4be 100644 --- a/src/layouts/components/ContactUs/ContactCard.tsx +++ b/src/layouts/components/ContactUs/ContactCard.tsx @@ -8,9 +8,9 @@ import { import _ from "lodash" import { ChangeEvent, useState } from "react" -import { useEditableContext } from "contexts/EditableContext" +import { Editable } from "components/Editable" -import { Editable } from "../Editable" +import { useEditableContext } from "contexts/EditableContext" type PhonePrefix = "singapore" | "toll-free" diff --git a/src/layouts/components/ContactUs/GeneralInfoSection.tsx b/src/layouts/components/ContactUs/GeneralInfoSection.tsx index 3aaae9030..be892079c 100644 --- a/src/layouts/components/ContactUs/GeneralInfoSection.tsx +++ b/src/layouts/components/ContactUs/GeneralInfoSection.tsx @@ -6,9 +6,9 @@ import { } from "@opengovsg/design-system-react" import { BiInfoCircle } from "react-icons/bi" -import { useEditableContext } from "contexts/EditableContext" +import { Editable } from "components/Editable" -import { Editable } from "../Editable" +import { useEditableContext } from "contexts/EditableContext" type GeneralInfoFrontMatter = { agency_name: string diff --git a/src/layouts/components/ContactUs/LocationCard.tsx b/src/layouts/components/ContactUs/LocationCard.tsx index dcc8d42af..61c051e35 100644 --- a/src/layouts/components/ContactUs/LocationCard.tsx +++ b/src/layouts/components/ContactUs/LocationCard.tsx @@ -9,10 +9,10 @@ import { import _ from "lodash" import { BiInfoCircle } from "react-icons/bi" -import { useEditableContext } from "contexts/EditableContext" +import { Editable } from "components/Editable" +import { AddSectionButton } from "components/Editable/AddSectionButton" -import { Editable } from "../Editable" -import { AddSectionButton } from "../Editable/AddSectionButton" +import { useEditableContext } from "contexts/EditableContext" export type LocationCardFrontMatter = { address: string[] diff --git a/src/layouts/components/Homepage/AnnouncementBody.tsx b/src/layouts/components/Homepage/AnnouncementBody.tsx new file mode 100644 index 000000000..6a4d708a7 --- /dev/null +++ b/src/layouts/components/Homepage/AnnouncementBody.tsx @@ -0,0 +1,253 @@ +import { Text, Box, FormControl } from "@chakra-ui/react" +import { DragDropContext } from "@hello-pangea/dnd" +import { + FormLabel, + Input, + FormErrorMessage, + Button, + DatePicker, + Textarea, +} from "@opengovsg/design-system-react" +import _ from "lodash" +import moment from "moment" +import { BiPlus } from "react-icons/bi" + +import { Editable } from "components/Editable/Editable" + +import { useEditableContext } from "contexts/EditableContext" + +import { AnnouncementError, AnnouncementOption } from "types/homepage" + +const MAX_ANNOUNCEMENTS = 5 + +interface AnnouncementBodyProps { + errors: { + announcementItems: AnnouncementError[] + } + announcementItems: Partial[] +} + +/** + * User to input a date like 01/01/2000. + * To be parsed into 01 January 2000. + * @param date user input string + * @returns front matter date string + */ +const toTemplateDateFormat = (date?: string) => { + const formattedDate = moment(date, "DD/MM/YYYY", true).format("DD MMMM YYYY") + if (formattedDate === "Invalid date") { + // return original string for validators to catch + return date + } + return formattedDate +} + +/** + * This will parse a date like 01 January 2000 + * into 01/01/2000 for the CMS panel. + * @param date front matter date string + * @returns cms panel date string + */ +const toCmsPanelDateFormat = (date?: string) => { + const formattedDate = moment(date, "DD MMMM YYYY", true).format("DD/MM/YYYY") + if (formattedDate === "Invalid date") { + // return original string for validators to catch + return date + } + return formattedDate +} + +export const AnnouncementBody = ({ + errors, + announcementItems = [], +}: AnnouncementBodyProps) => { + const { + onDragEnd, + onChange, + onCreate, + onDelete, + onDisplay, + } = useEditableContext() + return ( + + + + Announcements + + {`You can display up to ${MAX_ANNOUNCEMENTS} announcements at a time. Newly added + announcements are shown on the top of the list`} + + + onDisplay("announcement")}> + + + {announcementItems.map( + ( + { + title: announcementTitle, + date: announcementDate, + announcement: announcementContent, + link_text: announcementLinkText, + link_url: announcementLinkUrl, + }, + announcementIndex + ) => { + return ( + + + + Title + + + { + errors.announcementItems[announcementIndex] + .title + } + + + + Date + { + onChange({ + target: { + id: `announcement-${announcementIndex}-date`, + value: toTemplateDateFormat(value), + }, + }) + }} + /> + + {errors.announcementItems[announcementIndex].date} + + + + Announcement +