diff --git a/env.ts b/env.ts index 7357630..9d6baff 100644 --- a/env.ts +++ b/env.ts @@ -1,9 +1,9 @@ import { defineConfig, Schema } from '@julr/vite-plugin-validate-env'; -// TODO: Integrate .env for CI and remove optional() call on required fields export default defineConfig({ APP_TITLE: Schema.string.optional(), APP_MAPBOX_ACCESS_TOKEN: Schema.string(), APP_GRAPHQL_ENDPOINT: Schema.string.optional(), APP_GOOGLE_ANALYTICS_ID: Schema.string.optional(), -}) + APP_HCAPTCHA_SITEKEY: Schema.string.optional(), +}); \ No newline at end of file diff --git a/package.json b/package.json index 0e56e1a..6b0f040 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@apollo/client": "^3.9.9", "@graphql-codegen/introspection": "^4.0.3", "@graphql-codegen/typescript-operations": "^4.2.0", + "@hcaptcha/react-hcaptcha": "^1.11.0", "@ifrc-go/icons": "^1.3.3", "@ifrc-go/ui": "^1.1.2", "@mapbox/mapbox-gl-draw": "^1.4.3", @@ -29,6 +30,7 @@ "@sentry/react": "^7.81.1", "@togglecorp/fujs": "^2.1.1", "@togglecorp/re-map": "^0.2.0-beta-6", + "@togglecorp/toggle-form": "^2.0.4", "@turf/bbox": "^6.5.0", "@turf/circle": "^6.5.0", "graphql": "^16.8.1", diff --git a/src/App/routes/index.tsx b/src/App/routes/index.tsx index c6c6b48..e374ce3 100644 --- a/src/App/routes/index.tsx +++ b/src/App/routes/index.tsx @@ -57,6 +57,20 @@ const homeLayout = customWrapRoute({ }, }); +const mySubscription = customWrapRoute({ + parent: rootLayout, + path: 'subscriptions', + component: { + render: () => import('#views/MySubscription'), + props: {}, + }, + context: { + title: 'My Subscriptions', + // TODO: Change visibility after login feature + visibility: 'anything', + }, +}); + const homeIndex = customWrapRoute({ parent: homeLayout, index: true, @@ -178,6 +192,19 @@ const pageNotFound = customWrapRoute({ }, }); +const login = customWrapRoute({ + parent: rootLayout, + path: 'login', + component: { + render: () => import('#views/Login'), + props: {}, + }, + context: { + title: 'Login', + visibility: 'is-not-authenticated', + }, +}); + const wrappedRoutes = { rootLayout, homeLayout, @@ -190,6 +217,12 @@ const wrappedRoutes = { allSourcesFeeds, about, pageNotFound, +<<<<<<< HEAD + login, +||||||| parent of 3c22e5c (Add my subscription page) +======= + mySubscription, +>>>>>>> 3c22e5c (Add my subscription page) }; export const unwrappedRoutes = unwrapRoute(Object.values(wrappedRoutes)); diff --git a/src/components/Captcha/index.tsx b/src/components/Captcha/index.tsx new file mode 100644 index 0000000..f895d05 --- /dev/null +++ b/src/components/Captcha/index.tsx @@ -0,0 +1,85 @@ +import React, { useCallback } from 'react'; +import HCaptcha from '@hcaptcha/react-hcaptcha'; +import { + InputContainer, + InputContainerProps, +} from '@ifrc-go/ui'; + +import { hCaptchaKey } from '#config'; + +export type HCaptchaProps = Omit & { + name: T, + onChange: (value: string | undefined, name: T) => void; + elementRef?: React.RefObject; +}; + +function HCaptchaInput(props: HCaptchaProps) { + const { + actions, + actionsContainerClassName, + className, + disabled, + error, + errorContainerClassName, + hint, + hintContainerClassName, + icons, + iconsContainerClassName, + inputSectionClassName, + label, + readOnly, + name, + onChange, + elementRef, + } = props; + + const handleVerify = useCallback( + (token: string) => { + onChange(token, name); + }, + [onChange, name], + ); + const handleError = useCallback( + (err: string) => { + // eslint-disable-next-line no-console + console.error(err); + onChange(undefined, name); + }, + [onChange, name], + ); + const handleExpire = useCallback( + () => { + onChange(undefined, name); + }, + [onChange, name], + ); + + return ( + + )} + /> + ); +} + +export default HCaptchaInput; diff --git a/src/components/Navbar/i18n.json b/src/components/Navbar/i18n.json index 78595b2..9d2e622 100644 --- a/src/components/Navbar/i18n.json +++ b/src/components/Navbar/i18n.json @@ -6,6 +6,6 @@ "appAbout": "About", "appResources": "Resources", "headerMenuHome": "Home", - "headerMenuMySubscription": "My Subscription" + "headerMenuMySubscription": "My Subscriptions" } } diff --git a/src/components/Navbar/index.tsx b/src/components/Navbar/index.tsx index e095a6a..de2821f 100644 --- a/src/components/Navbar/index.tsx +++ b/src/components/Navbar/index.tsx @@ -1,5 +1,4 @@ import { - Button, Heading, NavigationTabList, PageContainer, @@ -60,13 +59,12 @@ function Navbar(props: Props) { > {strings.appResources} - + {strings.headerMenuHome} + + {strings.headerMenuMySubscription} + ); } + export default Navbar; diff --git a/src/components/Navbar/styles.module.css b/src/components/Navbar/styles.module.css index 7785e42..f976e94 100644 --- a/src/components/Navbar/styles.module.css +++ b/src/components/Navbar/styles.module.css @@ -50,7 +50,6 @@ } .menu-item:hover { - text-decoration: underline; color: var(--go-ui-color-primary-red); } diff --git a/src/config.ts b/src/config.ts index 4003e60..95e348b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -5,11 +5,13 @@ const { APP_TITLE, APP_COMMIT_HASH, APP_VERSION, + APP_HCAPTCHA_SITEKEY, } = import.meta.env; export const environment = APP_ENVIRONMENT; export const appTitle = APP_TITLE; export const api = APP_GRAPHQL_API_ENDPOINT; export const mapboxToken = APP_MAPBOX_ACCESS_TOKEN; +export const hCaptchaKey = APP_HCAPTCHA_SITEKEY; export const appCommitHash = APP_COMMIT_HASH; export const appVersion = APP_VERSION; diff --git a/src/views/Home/AlertFilters/index.tsx b/src/views/Home/AlertFilters/index.tsx index c4dc0b0..ac32b49 100644 --- a/src/views/Home/AlertFilters/index.tsx +++ b/src/views/Home/AlertFilters/index.tsx @@ -55,65 +55,65 @@ const categoryLabelSelector = (category: Category) => category.label; const ALERT_ENUMS = gql` query AlertEnums { enums { - AlertInfoCertainty { - key - label - } - AlertInfoUrgency { - label - key - } - AlertInfoSeverity { - key - label - } - AlertInfoCategory { - key - label - } + AlertInfoCertainty { + key + label + } + AlertInfoUrgency { + label + key + } + AlertInfoSeverity { + key + label + } + AlertInfoCategory { + key + label + } } }`; const ADMIN_LIST = gql` query FilteredAdminList($filters:Admin1Filter, $pagination: OffsetPaginationInput) { public { - id - admin1s(filters: $filters, pagination: $pagination) { - items { - id - name - countryId - alertCount + id + admin1s(filters: $filters, pagination: $pagination) { + items { + id + name + countryId + alertCount + } } - } } - } +} `; const REGION_LIST = gql` query RegionList { public { id - regions { - items { - id - name - ifrcGoId + regions { + items { + id + name + ifrcGoId + } } - } } - } +} `; const ALL_COUNTRY_LIST = gql` query AllCountryList { - public { - id - allCountries { - name - id + public { + id + allCountries { + name + id + } } - } } `; diff --git a/src/views/Home/AlertsMap/Sidebar/CountryDetail/index.tsx b/src/views/Home/AlertsMap/Sidebar/CountryDetail/index.tsx index aac0fd6..11b6e1f 100644 --- a/src/views/Home/AlertsMap/Sidebar/CountryDetail/index.tsx +++ b/src/views/Home/AlertsMap/Sidebar/CountryDetail/index.tsx @@ -36,23 +36,23 @@ import styles from './styles.module.css'; const COUNTRY_DETAIL = gql` query CountryDetail($countryId: ID!) { - public { - id - country(pk: $countryId) { - id - bbox - name - iso3 - ifrcGoId - alertCount - admin1s { + public { id - countryId - filteredAlertCount - name - } + country(pk: $countryId) { + id + bbox + name + iso3 + ifrcGoId + alertCount + admin1s { + id + countryId + filteredAlertCount + name + } + } } - } } `; diff --git a/src/views/Home/AlertsMap/i18n.json b/src/views/Home/AlertsMap/i18n.json index 64c9dee..dcca688 100644 --- a/src/views/Home/AlertsMap/i18n.json +++ b/src/views/Home/AlertsMap/i18n.json @@ -6,6 +6,7 @@ "ongoingAlertCountries": "Ongoing Alert Countries", "backToAlertsLabel": "Back to Alerts", "alertViewDetails": "View Details", - "alertInfo": "The IFRC AlertHub shows current warnings from official alerting agencies. These warnings have a start time (when the event might happen) and an end time (when it's expected to be over). The IFRC Alert Hub shows warnings that are happening right now (their start time has already passed) but aren't finished yet (their end time hasn't come yet)." + "alertInfo": "The IFRC AlertHub shows current warnings from official alerting agencies. These warnings have a start time (when the event might happen) and an end time (when it's expected to be over). The IFRC Alert Hub shows warnings that are happening right now (their start time has already passed) but aren't finished yet (their end time hasn't come yet).", + "alertNewSubscription": "New Subscription" } } diff --git a/src/views/Home/AlertsMap/index.tsx b/src/views/Home/AlertsMap/index.tsx index 0154322..e46a817 100644 --- a/src/views/Home/AlertsMap/index.tsx +++ b/src/views/Home/AlertsMap/index.tsx @@ -7,12 +7,19 @@ import { gql, useQuery, } from '@apollo/client'; -import { ChevronRightLineIcon } from '@ifrc-go/icons'; import { + AddLineIcon, + ChevronRightLineIcon, +} from '@ifrc-go/icons'; +import { + Button, Container, InfoPopup, } from '@ifrc-go/ui'; -import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + useBooleanState, + useTranslation, +} from '@ifrc-go/ui/hooks'; import { resolveToString } from '@ifrc-go/ui/utils'; import { isDefined, @@ -28,6 +35,7 @@ import { FilteredCountryListQueryVariables, } from '#generated/types/graphql'; import useFilterState from '#hooks/useFilterState'; +import NewSubscriptionModal from '#views/NewSubscriptionModal'; import AlertDataContext from '../AlertDataContext'; import AlertFilters from '../AlertFilters'; @@ -77,12 +85,21 @@ type AlertPointProperties = { export function Component() { const strings = useTranslation(i18n); const alertFilters = useAlertFilters(); + + const [showSubscriptionModal, { + setTrue: setShowSubscriptionModalTrue, + setFalse: setShowSubscriptionModalFalse, + }] = useBooleanState(false); + const { activeAdmin1Id, activeCountryId, activeAlertId, activeCountryDetails, activeAdmin1Details, + selectedUrgencyTypes, + selectedCertaintyTypes, + selectedSeverityTypes, } = useContext(AlertDataContext); // FIXME: We should remove useFilterState as we are not using any feature @@ -185,15 +202,30 @@ export function Component() { withHeaderBorder childrenContainerClassName={styles.mainContent} actions={( - - )} - > - {strings.mapViewAllSources} - +
+ + + )} + > + {strings.mapViewAllSources} + +
)} overlayPending pending={countryListLoading} @@ -204,6 +236,16 @@ export function Component() { filters={} withGridViewInFilter > + {showSubscriptionModal && ( + + )} - )} - > - {strings.tableViewAllSources} - +
+ + + )} + > + {strings.tableViewAllSources} + +
)} overlayPending pending={alertInfoLoading} @@ -310,6 +341,16 @@ export function Component() { )} filters={} > + {showSubscriptionModal && ( + + )} ; +type FormSchema = ObjectSchema; +type FormSchemaFields = ReturnType; + +const defaultFormValue: PartialFormFields = {}; + +const formSchema: FormSchema = { + fields: (): FormSchemaFields => ({ + email: { + required: true, + requiredValidation: requiredStringCondition, + }, + password: { + required: true, + requiredValidation: requiredStringCondition, + }, + captcha: { + required: true, + requiredValidation: requiredStringCondition, + }, + }), +}; + +// eslint-disable-next-line import/prefer-default-export +export function Component() { + const strings = useTranslation(i18n); + const { + value: formValue, + setFieldValue, + setError, + validate, + } = useForm(formSchema, { value: defaultFormValue }); + + const fieldError: PartialFormFields = {}; + + const handleFormSubmit = useMemo( + () => createSubmitHandler( + validate, + setError, + // FIXME: Add form submission logic here + () => {}, + ), + [validate, setError], + ); + + const signupInfo = resolveToComponent( + strings.loginDontHaveAccount, + { + signUpLink: ( + + {strings.loginSignUp} + + ), + }, + ); + + return ( + +
+
+ + +
+
+ + {strings.loginForgotUserPass} + + + {strings.loginResendValidation} + +
+
+ + +
+ {signupInfo} +
+
+ +
+ ); +} + +Component.displayName = 'Login'; diff --git a/src/views/Login/styles.module.css b/src/views/Login/styles.module.css new file mode 100644 index 0000000..f13ba8b --- /dev/null +++ b/src/views/Login/styles.module.css @@ -0,0 +1,39 @@ +.login { + .main-section { + display: flex; + justify-content: center; + + .form { + display: flex; + flex-direction: column; + flex-grow: 1; + gap: var(--go-ui-spacing-xl); + max-width: var(--go-ui-width-content-max); + + .fields { + display: flex; + flex-direction: column; + gap: var(--go-ui-spacing-xl); + } + + .utility-links { + display: flex; + flex-direction: column; + gap: var(--go-ui-spacing-sm); + align-items: flex-end; + } + + .actions { + display: flex; + flex-direction: column; + gap: var(--go-ui-spacing-lg); + align-items: center; + + .sign-up { + display: flex; + gap: var(--go-ui-spacing-sm); + } + } + } + } +} diff --git a/src/views/MySubscription/ActiveTableActions/i18n.json b/src/views/MySubscription/ActiveTableActions/i18n.json new file mode 100644 index 0000000..5739942 --- /dev/null +++ b/src/views/MySubscription/ActiveTableActions/i18n.json @@ -0,0 +1,8 @@ +{ + "namespace": "SubscriptionActions", + "strings": { + "deleteSubscriptionActions": "Delete", + "archiveSubscriptionActions": "Archive", + "editSubscriptionActions": "Edit" + } +} diff --git a/src/views/MySubscription/ActiveTableActions/index.tsx b/src/views/MySubscription/ActiveTableActions/index.tsx new file mode 100644 index 0000000..8073ab6 --- /dev/null +++ b/src/views/MySubscription/ActiveTableActions/index.tsx @@ -0,0 +1,42 @@ +import { MoreOptionsIcon } from '@ifrc-go/icons'; +import { DropdownMenu } from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; + +import DropdownMenuItem from '#components/DropdownMenuItem'; + +import i18n from './i18n.json'; + +function ActiveTableActions() { + const strings = useTranslation(i18n); + + return ( + } + variant="tertiary" + withoutDropdownIcon + > + + {strings.archiveSubscriptionActions} + + + {strings.editSubscriptionActions} + + + {strings.deleteSubscriptionActions} + + + ); +} + +export default ActiveTableActions; diff --git a/src/views/MySubscription/ArchiveTableActions/i18n.json b/src/views/MySubscription/ArchiveTableActions/i18n.json new file mode 100644 index 0000000..cd2fa01 --- /dev/null +++ b/src/views/MySubscription/ArchiveTableActions/i18n.json @@ -0,0 +1,7 @@ +{ + "namespace": "SubscriptionActions", + "strings": { + "unarchiveSubscriptionActions": "Unarchive", + "deleteSubscriptionActions": "Delete" + } +} diff --git a/src/views/MySubscription/ArchiveTableActions/index.tsx b/src/views/MySubscription/ArchiveTableActions/index.tsx new file mode 100644 index 0000000..aec03ac --- /dev/null +++ b/src/views/MySubscription/ArchiveTableActions/index.tsx @@ -0,0 +1,35 @@ +import { MoreOptionsIcon } from '@ifrc-go/icons'; +import { DropdownMenu } from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; + +import DropdownMenuItem from '#components/DropdownMenuItem'; + +import i18n from './i18n.json'; + +function ArchiveTableActions() { + const strings = useTranslation(i18n); + + return ( + } + variant="tertiary" + withoutDropdownIcon + > + + {strings.unarchiveSubscriptionActions} + + + {strings.deleteSubscriptionActions} + + + ); +} + +export default ArchiveTableActions; diff --git a/src/views/MySubscription/SubscriptionTableItem/index.tsx b/src/views/MySubscription/SubscriptionTableItem/index.tsx new file mode 100644 index 0000000..969bf12 --- /dev/null +++ b/src/views/MySubscription/SubscriptionTableItem/index.tsx @@ -0,0 +1,60 @@ +import { Container } from '@ifrc-go/ui'; + +import { + AlertInfoCertaintyEnum, + AlertInfoSeverityEnum, + AlertInfoUrgencyEnum, +} from '#generated/types/graphql'; + +import styles from './styles.module.css'; + +interface Props { + country: string | undefined; + admin1: string | undefined; + urgency?: AlertInfoUrgencyEnum[] | undefined; + certainty?: AlertInfoCertaintyEnum[] | undefined; + severity?: AlertInfoSeverityEnum[] | undefined; + title: string; + totalCount: number; + actions: React.ReactNode; +} + +function SubscriptionTableItem(props: Props) { + const { + country, + admin1, + urgency, + certainty, + severity, + title, + totalCount, + actions, + } = props; + + return ( + +
+
+ {title} +
+
+ ( + {totalCount} + ) + {actions} +
+
+
+ {country} + {admin1} + {urgency} + {certainty} + {severity} +
+
+ ); +} + +export default SubscriptionTableItem; diff --git a/src/views/MySubscription/SubscriptionTableItem/styles.module.css b/src/views/MySubscription/SubscriptionTableItem/styles.module.css new file mode 100644 index 0000000..301494d --- /dev/null +++ b/src/views/MySubscription/SubscriptionTableItem/styles.module.css @@ -0,0 +1,24 @@ +.subscription-detail { + background-color: var(--go-ui-color-gray-20); + padding: var(--go-ui-spacing-lg); + + .subscription-filters { + display: flex; + gap: var(--go-ui-spacing-lg); + justify-content: space-between; + + .alert-name { + font-weight: bold; + } + + .subscription-actions { + display: flex; + align-items: center; + } + } + + .filter-applied { + display: flex; + gap: var(--go-ui-spacing-lg); + } +} \ No newline at end of file diff --git a/src/views/MySubscription/common.tsx b/src/views/MySubscription/common.tsx new file mode 100644 index 0000000..a42195d --- /dev/null +++ b/src/views/MySubscription/common.tsx @@ -0,0 +1,24 @@ +import { + AlertInfoCertaintyEnum, + AlertInfoSeverityEnum, + AlertInfoUrgencyEnum, +} from '#generated/types/graphql'; + +export interface FrequencyOption { + label: string; + key: 'daily' | 'weekly'; +} + +// TODO: Add subscription interface from generated +export interface SubscriptionDetail { + id: string; + title: string; + country: string | undefined; + admin1: string | undefined; + urgency?: AlertInfoUrgencyEnum[] | undefined; + certainty?: AlertInfoCertaintyEnum[] | undefined; + severity?: AlertInfoSeverityEnum[] | undefined; + totalCount: number; + sendEmail?: boolean; + frequency?: 'daily' | 'weekly' | undefined; +} diff --git a/src/views/MySubscription/i18n.json b/src/views/MySubscription/i18n.json new file mode 100644 index 0000000..dd16309 --- /dev/null +++ b/src/views/MySubscription/i18n.json @@ -0,0 +1,11 @@ +{ + "namespace": "mySubscription", + "strings": { + "mySubscription": "My Subscription", + "myNewSubscription": "New Subscription", + "createNewSubscription": "Create", + "sendViaEmailLabel": "Send via email", + "activeSubscriptionsTab": "Active Subscriptions", + "archivedSubscriptionTab": "Archive Subscriptions" + } +} \ No newline at end of file diff --git a/src/views/MySubscription/index.tsx b/src/views/MySubscription/index.tsx new file mode 100644 index 0000000..623f84c --- /dev/null +++ b/src/views/MySubscription/index.tsx @@ -0,0 +1,169 @@ +import { + useCallback, + useState, +} from 'react'; +import { AddLineIcon } from '@ifrc-go/icons'; +import { + Button, + Container, + List, + Tab, + TabList, + TabPanel, + Tabs, +} from '@ifrc-go/ui'; +import { + useBooleanState, + useTranslation, +} from '@ifrc-go/ui/hooks'; + +import Page from '#components/Page'; + +import NewSubscriptionModal from '../NewSubscriptionModal'; +import ActiveTableActions from './ActiveTableActions'; +import ArchiveTableActions from './ArchiveTableActions'; +import { SubscriptionDetail } from './common'; +import SubscriptionTableItem from './SubscriptionTableItem'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +const subscriptionKeySelector = (subscription: SubscriptionDetail) => subscription.id; + +// eslint-disable-next-line import/prefer-default-export +export function Component() { + const strings = useTranslation(i18n); + + const data: SubscriptionDetail[] = [ + { + id: '1', + country: 'USA', + admin1: 'LA', + title: 'Earthquake Alert', + totalCount: 20, + urgency: [], + certainty: [], + severity: [], + }, + { + id: '2', + country: 'Canada', + admin1: 'Toronto', + title: 'Flood Alert', + totalCount: 30, + urgency: [], + certainty: [], + severity: [], + }, + ]; + + type TabKey = 'active' | 'archive'; + const [activeTab, setActiveTab] = useState('active'); + + const [showSubscriptionModal, { + setTrue: setShowSubscriptionModalTrue, + setFalse: setShowSubscriptionModalFalse, + }] = useBooleanState(false); + + const activeRendererParams = useCallback((_: string, value: SubscriptionDetail) => ({ + title: value.title, + totalCount: value.totalCount, + country: value?.country, + admin1: value?.admin1, + urgency: value?.urgency, + certainty: value?.certainty, + severity: value?.severity, + actions: , + }), []); + + const archiveRendererParams = useCallback((_: string, value: SubscriptionDetail) => ({ + title: value.title, + totalCount: value.totalCount, + country: value?.country, + admin1: value?.admin1, + urgency: value?.urgency, + certainty: value?.certainty, + severity: value?.severity, + actions: , + }), []); + + return ( + + + )} + > + {strings.myNewSubscription} + + )} + > + {showSubscriptionModal && ( + + )} + + + + {strings.activeSubscriptionsTab} + + + {strings.archivedSubscriptionTab} + + + + + + + + + + + + ); +} +Component.displayName = 'MySubscription'; diff --git a/src/views/MySubscription/styles.module.css b/src/views/MySubscription/styles.module.css new file mode 100644 index 0000000..663c717 --- /dev/null +++ b/src/views/MySubscription/styles.module.css @@ -0,0 +1,15 @@ +.mySubscription { + .content { + display: flex; + flex-direction: column; + gap: var(--go-ui-spacing-2xl); + + .subscriptions { + .subscription { + display: flex; + flex-direction: column; + gap: var(--go-ui-spacing-lg); + } + } + } +} \ No newline at end of file diff --git a/src/views/NewSubscriptionModal/i18n.json b/src/views/NewSubscriptionModal/i18n.json new file mode 100644 index 0000000..b687099 --- /dev/null +++ b/src/views/NewSubscriptionModal/i18n.json @@ -0,0 +1,21 @@ +{ + "namespace": "mySubscriptionModal", + "strings": { + "createNewSubscription": "Create", + "sendViaEmailLabel": "Send via email", + "filterCountriesPlaceholder": "All Countries", + "filterAdmin1Placeholder": "All Admin1", + "filterUrgencyPlaceholder": "All Urgency Types", + "filterSeverityPlaceholder": "All Severity Types", + "filterCertaintyPlaceholder": "All Certainty Types", + "filterCountriesLabel": "Country", + "filterAdmin1Label": "Admin1", + "filterUrgencyLabel": "Urgency Level", + "filterSeverityLabel": "Severity Level", + "filterCertaintyLabel": "Certainty Level", + "filterRegionsLabel": "Regions", + "filterRegionsPlaceholder": "All Regions", + "newSubscriptionHeading": "New Subscription", + "newSubscriptionTitle": "Title" + } +} \ No newline at end of file diff --git a/src/views/NewSubscriptionModal/index.tsx b/src/views/NewSubscriptionModal/index.tsx new file mode 100644 index 0000000..8cda835 --- /dev/null +++ b/src/views/NewSubscriptionModal/index.tsx @@ -0,0 +1,359 @@ +import { + useCallback, + useMemo, +} from 'react'; +import { + gql, + useQuery, +} from '@apollo/client'; +import { + Button, + Checkbox, + Modal, + MultiSelectInput, + RadioInput, + SelectInput, + TextInput, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { isNotDefined } from '@togglecorp/fujs'; +import { + getErrorObject, + type ObjectSchema, + type PartialForm, + requiredCondition, + requiredStringCondition, + useForm, +} from '@togglecorp/toggle-form'; + +import { + AlertEnumsAndAllCountryListQuery, + AlertEnumsAndAllCountryListQueryVariables, + AlertEnumsQuery, + AlertInfoCertaintyEnum, + AlertInfoSeverityEnum, + AlertInfoUrgencyEnum, + FilteredAdminListQuery, + FilteredAdminListQueryVariables, +} from '#generated/types/graphql'; +import { + stringIdSelector, + stringNameSelector, +} from '#utils/selectors'; +import { + FrequencyOption, + SubscriptionDetail, +} from '#views/MySubscription/common'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +const ALERT_ENUMS = gql` +query AlertEnumsAndAllCountryList { + enums { + AlertInfoCertainty { + key + label + } + AlertInfoUrgency { + key + label + } + AlertInfoSeverity { + key + label + } + AlertInfoCategory { + key + label + } + } + public { + id + allCountries { + name + id + } + } +} +`; + +const ADMIN_LIST = gql` +query FilteredAdminList($filters:Admin1Filter, $pagination: OffsetPaginationInput) { + public { + id + admin1s(filters: $filters, pagination: $pagination) { + items { + id + name + countryId + alertCount + } + } + } +} +`; + +type AdminOption = NonNullable['admin1s']>['items']>[number]; + +type Urgency = NonNullable[number]; +type Severity = NonNullable[number]; +type Certainty = NonNullable[number]; + +interface AlertFilters { + key: string; + label: string; +} + +interface Props { + subscription?: SubscriptionDetail; + onCloseModal?: () => void; + urgency?: AlertInfoUrgencyEnum[] | undefined; + severity?: AlertInfoSeverityEnum[] | undefined; + certainty?: AlertInfoCertaintyEnum[] | undefined; + activeCountry?: string | undefined; + activeAdmin1?: string | undefined; +} + +const adminKeySelector = (admin1: AdminOption) => admin1.id; +const urgencyKeySelector = (urgency: Urgency) => urgency.key; +const severityKeySelector = (severity: Severity) => severity.key; +const certaintyKeySelector = (certainty: Certainty) => certainty.key; +const labelSelector = (alert: AlertFilters) => alert.label; + +const frequencyKeySelector = (frequency: FrequencyOption) => frequency.key; +const frequencyLabelSelector = (frequency: FrequencyOption) => frequency.label; + +const frequencyOption: FrequencyOption[] = [ + { label: 'Daily', key: 'daily' }, + { label: 'Weekly', key: 'weekly' }, +]; + +type PartialFormFields = PartialForm; + +type FormSchema = ObjectSchema; +type FormSchemaFields = ReturnType + +const formSchema: FormSchema = { + fields: (): FormSchemaFields => ({ + title: { + required: true, + requiredValidation: requiredStringCondition, + }, + urgency: { + required: true, + requiredValidation: requiredCondition, + }, + severity: { + required: true, + requiredValidation: requiredCondition, + }, + certainty: { + required: true, + requiredValidation: requiredCondition, + }, + country: { + required: true, + requiredValidation: requiredCondition, + }, + admin1: { + required: true, + requiredValidation: requiredCondition, + }, + sendEmail: { + required: true, + requiredValidation: requiredCondition, + }, + frequency: { + required: true, + requiredValidation: requiredCondition, + }, + }), +}; + +function NewSubscriptionModal(props: Props) { + const { + subscription, + onCloseModal, + urgency, + severity, + certainty, + activeCountry, + activeAdmin1, + } = props; + + const defaultFormValue = useMemo(() => ({ + title: subscription?.title, + urgency: subscription?.urgency || urgency, + severity: subscription?.severity || severity, + certainty: subscription?.certainty || certainty, + sendEmail: subscription?.sendEmail, + frequency: subscription?.frequency, + country: subscription?.country || activeCountry, + admin1: subscription?.admin1 || activeAdmin1, + }), [ + subscription, + urgency, + severity, + certainty, + activeCountry, + activeAdmin1, + ]); + + const { + value, + setFieldValue, + error: formError, + // setError, + // validate, + } = useForm(formSchema, { value: defaultFormValue }); + + const fieldError = getErrorObject(formError); + + const strings = useTranslation(i18n); + const { + data: alertEnumsResponse, + } = useQuery( + ALERT_ENUMS, + ); + + const adminQueryVariables = useMemo( + () => { + if (isNotDefined(value.country)) { + return { + filters: undefined, + // FIXME: Implement search select input + pagination: { + offset: 0, + limit: 500, + }, + }; + } + + return { + filters: { + country: { pk: value.country }, + }, + // FIXME: Implement search select input + pagination: { + offset: 0, + limit: 500, + }, + }; + }, + [value.country], + ); + + const { + data: adminResponse, + } = useQuery( + ADMIN_LIST, + { variables: adminQueryVariables, skip: isNotDefined(value.country) }, + ); + + const subscriptionCreate = useCallback(() => { + // eslint-disable-next-line no-console + console.info('create'); + }, []); + + return ( + + {strings.createNewSubscription} + + )} + footerContentClassName={styles.createButton} + contentViewType="vertical" + spacing="comfortable" + onClose={onCloseModal} + > + +
+ + + + + +
+ + +
+ ); +} + +export default NewSubscriptionModal; diff --git a/src/views/NewSubscriptionModal/styles.module.css b/src/views/NewSubscriptionModal/styles.module.css new file mode 100644 index 0000000..7c566bd --- /dev/null +++ b/src/views/NewSubscriptionModal/styles.module.css @@ -0,0 +1,13 @@ +.subscription-modal { + .create-button { + display: flex; + flex-direction: column; + align-items: flex-start; + } + + .filters { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr)); + gap: var(--go-ui-spacing-md); + } +} \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts index 97d5b48..45403fd 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -9,6 +9,7 @@ import { ValidateEnv as validateEnv } from '@julr/vite-plugin-validate-env'; import { VitePluginRadar } from 'vite-plugin-radar'; import alertHubPackage from './package.json'; +import envConfig from './env'; /* Get commit hash */ const commitHash = execSync('git rev-parse --short HEAD').toString(); @@ -35,7 +36,7 @@ export default defineConfig(({ mode }) => { reactSwc(), tsconfigPaths(), webfontDownload(), - validateEnv(), + validateEnv(envConfig), isProd ? compression() : undefined, VitePluginRadar({ analytics: { diff --git a/yarn.lock b/yarn.lock index 5ca4e2a..44d5ccf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -502,6 +502,13 @@ dependencies: regenerator-runtime "^0.14.0" +"@babel/runtime@^7.17.9": + version "7.26.0" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.26.0.tgz#8600c2f595f277c60815256418b85356a65173c1" + integrity sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw== + dependencies: + regenerator-runtime "^0.14.0" + "@babel/template@^7.18.10", "@babel/template@^7.20.7", "@babel/template@^7.22.15", "@babel/template@^7.24.0": version "7.24.0" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.24.0.tgz#c6a524aa93a4a05d66aaf31654258fae69d87d50" @@ -1655,6 +1662,19 @@ resolved "https://registry.yarnpkg.com/@graphql-typed-document-node/core/-/core-3.2.0.tgz#5f3d96ec6b2354ad6d8a28bf216a1d97b5426861" integrity sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ== +"@hcaptcha/loader@^1.2.1": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@hcaptcha/loader/-/loader-1.2.4.tgz#541714395a82e27ec0f0e8bd80ef1a0bea141cc3" + integrity sha512-3MNrIy/nWBfyVVvMPBKdKrX7BeadgiimW0AL/a/8TohNtJqxoySKgTJEXOQvYwlHemQpUzFrIsK74ody7JiMYw== + +"@hcaptcha/react-hcaptcha@^1.11.0": + version "1.11.0" + resolved "https://registry.yarnpkg.com/@hcaptcha/react-hcaptcha/-/react-hcaptcha-1.11.0.tgz#d4520dd3b7ece735bc928dedcda048be477b2dcd" + integrity sha512-UKHtzzVMHLTGwab5pgV96UbcXdyh5Qyq8E0G5DTyXq8txMvuDx7rSyC+BneOjWVW0a7O9VuZmkg/EznVLRE45g== + dependencies: + "@babel/runtime" "^7.17.9" + "@hcaptcha/loader" "^1.2.1" + "@humanwhocodes/config-array@^0.11.14": version "0.11.14" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.14.tgz#d78e481a039f7566ecc9660b4ea7fe6b1fec442b" @@ -2424,6 +2444,14 @@ "@babel/runtime-corejs3" "^7.22.3" "@togglecorp/fujs" "^2.1.0" +"@togglecorp/toggle-form@^2.0.4": + version "2.0.4" + resolved "https://registry.yarnpkg.com/@togglecorp/toggle-form/-/toggle-form-2.0.4.tgz#2098ae24d6a37020d19a60162fff457e7a9eedf3" + integrity sha512-+EzRzXK/PKlisu44yARpxOkoeowz+0oKk2Rl3CdhxtBfTVfzG28aHAklDTubTBssS8hneGBTav2aInCqmwChfg== + dependencies: + "@babel/runtime-corejs3" "^7.22.3" + "@togglecorp/fujs" "^2.1.1" + "@tsconfig/node10@^1.0.7": version "1.0.11" resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.11.tgz#6ee46400685f130e278128c7b38b7e031ff5b2f2"