diff --git a/api/nginx.conf b/api/nginx.conf index 16f1d0576..9ef1a6f40 100644 --- a/api/nginx.conf +++ b/api/nginx.conf @@ -2,6 +2,10 @@ server { listen 80; server_name api; + gzip on; + gzip_comp_level 2; + gzip_types text/plain text/csv text/css application/json text/javascript; + location / { alias /usr/share/nginx/html/api/; autoindex on; diff --git a/docker-compose.yaml b/docker-compose.yaml index fa6fd06c7..ddd85c0d9 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -138,6 +138,10 @@ services: REACT_APP_SENTRY_DSN: ${MANAGER_DASHBOARD_SENTRY_DSN} REACT_APP_SENTRY_TRACES_SAMPLE_RATE: ${MANAGER_DASHBOARD_SENTRY_TRACES_SAMPLE_RATE} REACT_APP_COMMUNITY_DASHBOARD_URL: ${MANAGER_DASHBOARD_COMMUNITY_DASHBOARD_URL} + REACT_APP_IMAGE_BING_API_KEY: ${IMAGE_BING_API_KEY} + REACT_APP_IMAGE_MAPBOX_API_KEY: ${IMAGE_MAPBOX_API_KEY} + REACT_APP_IMAGE_MAXAR_PREMIUM_API_KEY: ${IMAGE_MAXAR_PREMIUM_API_KEY} + REACT_APP_IMAGE_MAXAR_STANDARD_API_KEY: ${IMAGE_MAXAR_STANDARD_API_KEY} volumes: - manager-dashboard-static:/code/build/ command: bash -c 'yarn build' diff --git a/firebase/functions/src/index.ts b/firebase/functions/src/index.ts index d56e3da50..803d81186 100644 --- a/firebase/functions/src/index.ts +++ b/firebase/functions/src/index.ts @@ -30,7 +30,7 @@ exports.osmAuth.token = functions.https.onRequest((req, res) => { This function also writes to the `contributions` section in the user profile. */ -exports.groupUsersCounter = functions.database.ref('/v2/results/{projectId}/{groupId}/{userId}/').onCreate((snapshot, context) => { +exports.groupUsersCounter = functions.database.ref('/v2/results/{projectId}/{groupId}/{userId}/').onCreate(async (snapshot, context) => { // these references/values will be updated by this function const groupUsersRef = admin.database().ref('/v2/groupsUsers/' + context.params.projectId + '/' + context.params.groupId); const userRef = admin.database().ref('/v2/users/' + context.params.userId); @@ -53,6 +53,30 @@ exports.groupUsersCounter = functions.database.ref('/v2/results/{projectId}/{gro } const result = snapshot.val(); + + + // New versions of app will have the appVersion defined (> 2.2.5) + // appVersion: 2.2.5 (14)-dev + const appVersionString = result.appVersion as string | undefined | null; + + // Check if the app is of older version + // (no need to check for specific version since old app won't sent the version info) + if (appVersionString === null || appVersionString === undefined || appVersionString.trim() === '') { + const projectRef = admin.database().ref(`/v2/projects/${context.params.projectId}`); + const dataSnapshot = await projectRef.once('value'); + + if (dataSnapshot.exists()) { + const project = dataSnapshot.val(); + // Check if project type is footprint and also has + // custom options (i.e. these are new type of projects) + if (project.projectType === 2 && project.customOptions) { + // We remove the results submitted from older version of app (< v2.2.6) + console.info(`Result submitted for ${context.params.projectId} was discarded: submitted from older version of app`); + return thisResultRef.remove(); + } + } + } + // if result ref does not contain all required attributes we don't updated counters // e.g. due to some error when uploading from client if (!Object.prototype.hasOwnProperty.call(result, 'results')) { @@ -90,77 +114,72 @@ exports.groupUsersCounter = functions.database.ref('/v2/results/{projectId}/{gro Update overall taskContributionCount and project taskContributionCount in the user profile based on the number of results submitted and the existing count values. */ - return groupUsersRef.child(context.params.userId).once('value') - .then((dataSnapshot) => { - if (dataSnapshot.exists()) { - console.log('group contribution exists already. user: '+context.params.userId+' project: '+context.params.projectId+' group: '+context.params.groupId); - return null; - } - const latestNumberOfTasks = Object.keys(result['results']).length; - - return Promise.all([ - userContributionRef.child(context.params.groupId).set(true), - groupUsersRef.child(context.params.userId).set(true), - totalTaskContributionCountRef.transaction((currentCount) => { - return currentCount + latestNumberOfTasks; - }), - totalGroupContributionCountRef.transaction((currentCount) => { - return currentCount + 1; - }), - taskContributionCountRef.transaction((currentCount) => { - return currentCount + latestNumberOfTasks; - }), - - // Tag userGroups of the user in the result - // eslint-disable-next-line promise/no-nesting - userRef.child('userGroups').once('value').then((userGroupsOfTheUserSnapshot) => { - if (!userGroupsOfTheUserSnapshot.exists()) { - return null; - } - - // eslint-disable-next-line promise/no-nesting - return userGroupsRef.once('value').then((allUserGroupsSnapshot) => { - if (!allUserGroupsSnapshot.exists()) { - return null; - } - - const userGroupsOfTheUserKeyList = Object.keys(userGroupsOfTheUserSnapshot.val()); - if (userGroupsOfTheUserKeyList.length <= 0) { - return null; - } - - const allUserGroups = allUserGroupsSnapshot.val(); - const nonArchivedUserGroupKeys = userGroupsOfTheUserKeyList.filter((key) => { - const currentUserGroup = allUserGroups[key]; - - // User might have joined some group that was removed but not cleared from their list - if (!currentUserGroup) { - return false; - } - - // Skip groups that have been archived - if (currentUserGroup.archivedAt) { - return false; - } - - return true; - }); - - if (nonArchivedUserGroupKeys.length === 0) { - return null; - } - - const nonArchivedUserGroupsOfTheUser = nonArchivedUserGroupKeys.reduce((acc, val) => { - acc[val] = true; - return acc; - }, {} as Record); - - // Include userGroups of the user in the results - return thisResultRef.child('userGroups').set(nonArchivedUserGroupsOfTheUser); - }); - }), - ]); - }); + const dataSnapshot = await groupUsersRef.child(context.params.userId).once('value'); + if (dataSnapshot.exists()) { + console.log('group contribution exists already. user: '+context.params.userId+' project: '+context.params.projectId+' group: '+context.params.groupId); + return null; + } + + const latestNumberOfTasks = Object.keys(result['results']).length; + await Promise.all([ + userContributionRef.child(context.params.groupId).set(true), + groupUsersRef.child(context.params.userId).set(true), + totalTaskContributionCountRef.transaction((currentCount) => { + return currentCount + latestNumberOfTasks; + }), + totalGroupContributionCountRef.transaction((currentCount) => { + return currentCount + 1; + }), + taskContributionCountRef.transaction((currentCount) => { + return currentCount + latestNumberOfTasks; + }), + ]); + + + // Tag userGroups of the user in the result + const userGroupsOfTheUserSnapshot = await userRef.child('userGroups').once('value'); + if (!userGroupsOfTheUserSnapshot.exists()) { + return null; + } + + const allUserGroupsSnapshot = await userGroupsRef.once('value'); + if (!allUserGroupsSnapshot.exists()) { + return null; + } + + const userGroupsOfTheUserKeyList = Object.keys(userGroupsOfTheUserSnapshot.val()); + if (userGroupsOfTheUserKeyList.length <= 0) { + return null; + } + + const allUserGroups = allUserGroupsSnapshot.val(); + const nonArchivedUserGroupKeys = userGroupsOfTheUserKeyList.filter((key) => { + const currentUserGroup = allUserGroups[key]; + + // User might have joined some group that was removed but not cleared from their list + if (!currentUserGroup) { + return false; + } + + // Skip groups that have been archived + if (currentUserGroup.archivedAt) { + return false; + } + + return true; + }); + + if (nonArchivedUserGroupKeys.length === 0) { + return null; + } + + const nonArchivedUserGroupsOfTheUser = nonArchivedUserGroupKeys.reduce((acc, val) => { + acc[val] = true; + return acc; + }, {} as Record); + + // Include userGroups of the user in the results + return thisResultRef.child('userGroups').set(nonArchivedUserGroupsOfTheUser); }); diff --git a/manager-dashboard/.env.example b/manager-dashboard/.env.example index 453a97527..6df3985d3 100644 --- a/manager-dashboard/.env.example +++ b/manager-dashboard/.env.example @@ -10,3 +10,9 @@ REACT_APP_FIREBASE_STORAGE_BUCKET= REACT_APP_FIREBASE_MESSAGING_SENDER_ID= REACT_APP_FIREBASE_APP_ID= REACT_APP_COMMUNITY_DASHBOARD_URL= + +REACT_APP_IMAGE_BING_API_KEY= +REACT_APP_IMAGE_MAPBOX_API_KEY= +REACT_APP_IMAGE_MAXAR_PREMIUM_API_KEY= +# -- NOTE: not used and seems to be discontinued +REACT_APP_IMAGE_MAXAR_STANDARD_API_KEY= diff --git a/manager-dashboard/app/Base/components/Navbar/styles.css b/manager-dashboard/app/Base/components/Navbar/styles.css index 30110593c..869c7b47b 100644 --- a/manager-dashboard/app/Base/components/Navbar/styles.css +++ b/manager-dashboard/app/Base/components/Navbar/styles.css @@ -5,7 +5,7 @@ box-shadow: 0 3px 5px -2px var(--color-shadow); background-color: var(--color-primary); padding: var(--spacing-medium) var(--spacing-large); - color: var(--color-text-on-light); + color: var(--color-text-on-dark); .container { display: flex; @@ -42,7 +42,7 @@ .link { opacity: 0.7; - color: var(--color-text-on-light); + color: var(--color-text-on-dark); &.active { opacity: 1; @@ -58,7 +58,7 @@ gap: var(--spacing-medium); .logout-button { - color: var(--color-text-on-light); + color: var(--color-text-on-dark); } } } diff --git a/manager-dashboard/app/Base/components/SmartLink/index.tsx b/manager-dashboard/app/Base/components/SmartLink/index.tsx index 205e757e7..d0d5bbe31 100644 --- a/manager-dashboard/app/Base/components/SmartLink/index.tsx +++ b/manager-dashboard/app/Base/components/SmartLink/index.tsx @@ -3,7 +3,7 @@ import { Link, LinkProps } from 'react-router-dom'; import { useButtonFeatures, - Props as ButtonProps, + ButtonProps, } from '#components/Button'; import useRouteMatching, { diff --git a/manager-dashboard/app/Base/index.tsx b/manager-dashboard/app/Base/index.tsx index a41d73148..f2bd6e24c 100644 --- a/manager-dashboard/app/Base/index.tsx +++ b/manager-dashboard/app/Base/index.tsx @@ -12,6 +12,7 @@ import { ApolloProvider, } from '@apollo/client'; import { initializeApp } from 'firebase/app'; +import 'react-mde/lib/styles/css/react-mde-all.css'; import Init from '#base/components/Init'; import PreloadMessage from '#base/components/PreloadMessage'; diff --git a/manager-dashboard/app/Base/styles.css b/manager-dashboard/app/Base/styles.css index c599ea183..306f01caf 100644 --- a/manager-dashboard/app/Base/styles.css +++ b/manager-dashboard/app/Base/styles.css @@ -53,6 +53,8 @@ p { --color-background-primary-hint: #0d194920; --color-background-accent-hint: #4746f610; + --color-black: #000000; + --color-scrollbar-foreground: rgba(0, 0, 0, .2); --color-scrollbar-background: rgba(0, 0, 0, .01); --color-hover-background: rgba(0, 0, 0, 0.03); @@ -60,14 +62,17 @@ p { --color-primary: #0d1949; --color-primary-light: #2d5989; - --color-text-on-light: #ffffff; + --color-text-on-dark: #ffffff; --color-text-watermark: rgba(0, 0, 0, .4); --color-text: rgba(0, 0, 0, 0.8); --color-text-dark: rgba(0, 0, 0, 1); --color-text-light: rgba(0, 0, 0, 0.6); + --color-text-gray: #666666; --color-accent: #4746f6; --color-danger: #e04656; --color-success: #53b391; + --color-banner-alert: #fdf4dc; + --color-banner-text: #5C4813; --color-separator: rgba(0, 0, 0, 0.1); --color-shadow: rgba(0, 0, 0, 0.2); @@ -83,24 +88,40 @@ p { --font-size-ultra-large: 4rem; --font-weight-medium: 400; + --font-weight-semibold: 600; --font-weight-bold: 700; --opacity-disabled-element: 0.5; - --width-separator-thin: 1px; + --width-separator-thin: 1pt; + --width-separator-thick: 5pt; + --width-separator-mobile-preview: 8pt; --width-scrollbar: 0.75rem; --width-page-content-max: 70rem; --width-navbar-content-max: 76rem; + --width-mobile-preview: 22rem; + --height-mobile-preview: 40rem; + --height-mobile-preview-builarea-content: 30rem; + --height-mobile-preview-footprint-content: 22rem; + --height-mobile-preview-change-detection-content: 14rem; + --radius-popup-border: 0.25rem; --radius-scrollbar-border: 0.25rem; --radius-blur-shadow: 5px; --radius-modal: 0.25rem; --radius-button-border: 0.25rem; --radius-input-border: 0.25rem; - --radius-card-border: 0.25rem; + --radius-card-border: 0.5rem; --radius-badge-border: 1rem; + --line-height-none: 1; + --line-height-tight: 1.25; + --line-height-snug: 1.375; + --line-height-normal: 1.5; + --line-height-relaxed: 1.625; + --line-height-loose: 2; + --shadow-card: 0 2px 4px -2px var(--color-shadow); diff --git a/manager-dashboard/app/Base/utils/errorTransform.ts b/manager-dashboard/app/Base/utils/errorTransform.ts index 7853b99f1..1ca65effe 100644 --- a/manager-dashboard/app/Base/utils/errorTransform.ts +++ b/manager-dashboard/app/Base/utils/errorTransform.ts @@ -1,8 +1,8 @@ -import { internal } from '@togglecorp/toggle-form'; +import { nonFieldError } from '@togglecorp/toggle-form'; import { listToMap, isDefined, isNotDefined } from '@togglecorp/fujs'; interface Error { - [internal]?: string | undefined; + [nonFieldError]?: string | undefined; [key: string]: string | Error | undefined; } @@ -53,7 +53,7 @@ function transformObject(errors: ObjectError[] | undefined): Error | undefined { ); return { - [internal]: finalNonFieldErrors, + [nonFieldError]: finalNonFieldErrors, ...finalFieldErrors, }; } @@ -68,7 +68,7 @@ function transformArray(errors: (ArrayError | null)[] | undefined): Error | unde const memberErrors = filteredErrors.filter((error) => error.clientId !== 'nonMemberErrors'); return { - [internal]: topLevelError?.messages, + [nonFieldError]: topLevelError?.messages, ...listToMap( memberErrors, (error) => error.clientId, diff --git a/manager-dashboard/app/components/AlertBanner/index.tsx b/manager-dashboard/app/components/AlertBanner/index.tsx new file mode 100644 index 000000000..08e439be3 --- /dev/null +++ b/manager-dashboard/app/components/AlertBanner/index.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { IoAlertCircleOutline } from 'react-icons/io5'; +import { _cs } from '@togglecorp/fujs'; + +import Heading from '#components/Heading'; + +import styles from './styles.css'; + +interface Props { + className?: string; + title?: React.ReactNode; + children: React.ReactNode; +} + +function AlertBanner(props: Props) { + const { + className, + children, + title, + } = props; + + return ( +
+
+ +
+
+ {title && ( + + {title} + + )} + {children} +
+
+ ); +} + +export default AlertBanner; diff --git a/manager-dashboard/app/components/AlertBanner/styles.css b/manager-dashboard/app/components/AlertBanner/styles.css new file mode 100644 index 000000000..770bb8e83 --- /dev/null +++ b/manager-dashboard/app/components/AlertBanner/styles.css @@ -0,0 +1,17 @@ +.banner { + display: flex; + gap: var(--spacing-large); + border-radius: var(--radius-card-border); + background-color: var(--color-banner-alert); + padding: var(--spacing-large); + + .banner-icon { + font-size: var(--font-size-super-large); + } + + .container { + display: flex; + flex-direction: column; + gap: var(--spacing-small); + } +} diff --git a/manager-dashboard/app/components/Button/index.tsx b/manager-dashboard/app/components/Button/index.tsx index dad831ead..416c24e90 100644 --- a/manager-dashboard/app/components/Button/index.tsx +++ b/manager-dashboard/app/components/Button/index.tsx @@ -13,7 +13,7 @@ export type ButtonVariant = ( | 'transparent' ); -export interface Props extends RawButtonProps { +export interface ButtonProps extends RawButtonProps { /** * Variant of the button */ @@ -57,7 +57,7 @@ export interface Props extends RawButtonProps { type ButtonFeatureKeys = 'variant' | 'className' | 'actionsClassName' | 'iconsClassName' | 'childrenClassName' | 'children' | 'icons' | 'actions' | 'disabled'; export function useButtonFeatures( - props: Pick, ButtonFeatureKeys>, + props: Pick, ButtonFeatureKeys>, ) { const { variant = 'default', @@ -111,7 +111,7 @@ export function useButtonFeatures( /** * Basic button component */ -function Button(props: Props) { +function Button(props: ButtonProps) { const { variant, className, diff --git a/manager-dashboard/app/components/Button/styles.css b/manager-dashboard/app/components/Button/styles.css index 5bd4987a7..2670bc56d 100644 --- a/manager-dashboard/app/components/Button/styles.css +++ b/manager-dashboard/app/components/Button/styles.css @@ -4,6 +4,7 @@ border: var(--width-separator-thin) solid rgba(0, 0, 0, 0.3); border-radius: var(--radius-button-border); padding: var(--spacing-small) var(--spacing-medium); + width: fit-content; color: rgba(0, 0, 0, 0.8); gap: var(--spacing-small); diff --git a/manager-dashboard/app/components/EmptyMessage/index.tsx b/manager-dashboard/app/components/EmptyMessage/index.tsx new file mode 100644 index 000000000..2c45d633b --- /dev/null +++ b/manager-dashboard/app/components/EmptyMessage/index.tsx @@ -0,0 +1,28 @@ +import React from 'react'; + +import styles from './styles.css'; + +interface Props { + title: string; + description: string; +} + +function EmptyMessage(props: Props) { + const { + title, + description, + } = props; + + return ( +
+
+ {title} +
+
+ {description} +
+
+ ); +} + +export default EmptyMessage; diff --git a/manager-dashboard/app/components/EmptyMessage/styles.css b/manager-dashboard/app/components/EmptyMessage/styles.css new file mode 100644 index 000000000..0b0e979e0 --- /dev/null +++ b/manager-dashboard/app/components/EmptyMessage/styles.css @@ -0,0 +1,19 @@ +.empty-message { + display: flex; + align-items: center; + flex-direction: column; + justify-content: center; + gap: var(--spacing-small); + padding: var(--spacing-extra-large); + color: var(--color-text-gray); + + .empty-message-title { + font-size: var(--font-size-large); + font-weight: var(--font-weight-bold); + } + + .empty-message-description { + font-size: var(--font-size-medium); + font-weight: var(--font-weight-medium); + } +} \ No newline at end of file diff --git a/manager-dashboard/app/components/ExpandableContainer/index.tsx b/manager-dashboard/app/components/ExpandableContainer/index.tsx new file mode 100644 index 000000000..c94b8784b --- /dev/null +++ b/manager-dashboard/app/components/ExpandableContainer/index.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { IoIosArrowDown, IoIosArrowUp } from 'react-icons/io'; +import { _cs } from '@togglecorp/fujs'; + +import Button from '#components/Button'; + +import styles from './styles.css'; + +interface Props { + icons?: React.ReactNode; + header?: React.ReactNode; + actions?: React.ReactNode; + className?: string; + children?: React.ReactNode; + openByDefault?: boolean; +} + +function ExpandableContainer(props: Props) { + const { + children, + className, + icons, + header, + actions, + openByDefault = false, + } = props; + + const [isExpanded, setIsExpanded] = React.useState(openByDefault); + + return ( +
+
+ {icons && ( +
+ {icons} +
+ )} +
+ {header} +
+
+ {actions} + +
+
+ {isExpanded && ( +
+ {children} +
+ )} +
+ ); +} + +export default ExpandableContainer; diff --git a/manager-dashboard/app/components/ExpandableContainer/styles.css b/manager-dashboard/app/components/ExpandableContainer/styles.css new file mode 100644 index 000000000..bab691f46 --- /dev/null +++ b/manager-dashboard/app/components/ExpandableContainer/styles.css @@ -0,0 +1,53 @@ +.expandable-container { + border-radius: var(--radius-card-border); + color: inherit; + + .header-container { + display: flex; + border: var(--width-separator-thin) solid var(--color-separator); + border-top-left-radius: var(--radius-card-border); + border-top-right-radius: var(--radius-card-border); + background-color: var(--color-background-accent-hint); + padding: var(--spacing-medium); + + .header { + flex-grow: 1; + font-size: var(--font-size-large); + } + + .icons { + display: flex; + gap: var(--spacing-medium); + flex-shrink: 0; + + >* { + font-size: var(--font-size-large); + } + } + + .actions { + display: flex; + gap: var(--spacing-medium); + flex-shrink: 0; + + >* { + font-size: var(--font-size-large); + } + } + } + + .children { + border: var(--width-separator-thin) solid var(--color-separator); + border-top: 0; + border-bottom-left-radius: var(--radius-card-border); + border-bottom-right-radius: var(--radius-card-border); + padding: var(--spacing-medium); + } + + &:not(.expanded) { + .header-container { + border-bottom-left-radius: var(--radius-card-border); + border-bottom-right-radius: var(--radius-card-border); + } + } +} diff --git a/manager-dashboard/app/components/FileInput/index.tsx b/manager-dashboard/app/components/FileInput/index.tsx index ca4b975bd..207c9f31b 100644 --- a/manager-dashboard/app/components/FileInput/index.tsx +++ b/manager-dashboard/app/components/FileInput/index.tsx @@ -8,60 +8,10 @@ import { MdAttachFile } from 'react-icons/md'; import { useButtonFeatures } from '#components/Button'; import RawInput from '#components/RawInput'; import InputContainer, { Props as InputContainerProps } from '#components/InputContainer'; +import Preview from '#components/Preview'; import styles from './styles.css'; -interface PreviewProps { - file: File | null | undefined; - className?: string; -} - -function Preview(props: PreviewProps) { - const { - file, - className, - } = props; - - const isPreviewable = file?.name?.match(/.(jpg|jpeg|png|gif)$/i) ?? false; - const [imageUrl, setImageUrl] = React.useState(); - - React.useEffect(() => { - if (!file) { - return undefined; - } - - // FIXME: use async methods - const fileReader = new FileReader(); - - const handleFileLoad = () => { - setImageUrl(String(fileReader.result) ?? undefined); - }; - - fileReader.addEventListener('load', handleFileLoad); - fileReader.readAsDataURL(file); - - return () => { - fileReader.removeEventListener('load', handleFileLoad); - }; - }, [file]); - - if (!isPreviewable) { - return ( -
- Preview not available -
- ); - } - - return ( - {file?.name} - ); -} - export interface Props extends Omit { value: File | undefined | null; name: Name; @@ -104,7 +54,7 @@ function FileInput(props: Props) { Select file ), - variant: 'action', + variant: 'secondary', className: styles.label, childrenClassName: styles.content, }); @@ -170,7 +120,6 @@ function FileInput(props: Props) { {description} {showPreview && ( )} @@ -189,6 +138,7 @@ function FileInput(props: Props) { name={name} onChange={handleChange} accept={accept} + disabled={disabled} /> )} diff --git a/manager-dashboard/app/components/FileInput/styles.css b/manager-dashboard/app/components/FileInput/styles.css index f9102b3b0..f590c0854 100644 --- a/manager-dashboard/app/components/FileInput/styles.css +++ b/manager-dashboard/app/components/FileInput/styles.css @@ -31,23 +31,3 @@ } } -.no-preview { - display: flex; - align-items: center; - justify-content: center; - padding: var(--spacing-medium); - width: 100%; - max-width: 30rem; - height: 20rem; - color: var(--color-text-watermark); - font-size: var(--font-size-large); -} - -.preview { - background-color: var(--color-input-background); - width: 100%; - max-width: 30rem; - height: 20rem; - object-fit: contain; - object-position: center center; -} diff --git a/manager-dashboard/app/components/GeoJsonFileInput/index.tsx b/manager-dashboard/app/components/GeoJsonFileInput/index.tsx index acc981f40..cbb9bf731 100644 --- a/manager-dashboard/app/components/GeoJsonFileInput/index.tsx +++ b/manager-dashboard/app/components/GeoJsonFileInput/index.tsx @@ -53,6 +53,7 @@ interface Props extends Omit, 'value' | 'onChange' | 'accep maxFileSize?: number; value: GeoJSON.GeoJSON | undefined | null; onChange: (newValue: GeoJSON.GeoJSON | undefined, name: N) => void; + preview?: boolean; } function GeoJsonFileInput(props: Props) { @@ -63,6 +64,7 @@ function GeoJsonFileInput(props: Props) { maxFileSize = DEFAULT_MAX_FILE_SIZE, onChange, name, + preview = false, ...otherProps } = props; @@ -109,6 +111,7 @@ function GeoJsonFileInput(props: Props) { } const parsedGeoJSON = parseGeoJSON(text); + if (!parsedGeoJSON.errored) { fileAsJson = parsedGeoJSON.value; } else { @@ -143,9 +146,11 @@ function GeoJsonFileInput(props: Props) { description={( <> {description} - + {preview && ( + + )} )} onChange={handleChange} diff --git a/manager-dashboard/app/components/GeoJsonPreview/index.tsx b/manager-dashboard/app/components/GeoJsonPreview/index.tsx index 59d7a3599..b881e96e9 100644 --- a/manager-dashboard/app/components/GeoJsonPreview/index.tsx +++ b/manager-dashboard/app/components/GeoJsonPreview/index.tsx @@ -2,22 +2,62 @@ import React from 'react'; import { map as createMap, Map, - tileLayer, geoJSON, + TileLayer, + Coords, + StyleFunction, } from 'leaflet'; import { _cs } from '@togglecorp/fujs'; import styles from './styles.css'; +const toQuadKey = (x: number, y: number, z: number) => { + let index = ''; + for (let i = z; i > 0; i -= 1) { + let b = 0; + // eslint-disable-next-line no-bitwise + const mask = 1 << (i - 1); + // eslint-disable-next-line no-bitwise + if ((x & mask) !== 0) { + b += 1; + } + // eslint-disable-next-line no-bitwise + if ((y & mask) !== 0) { + b += 2; + } + index += b.toString(); + } + return index; +}; + +const BingTileLayer = TileLayer.extend({ + getTileUrl(coords: Coords) { + const quadkey = toQuadKey(coords.x, coords.y, coords.z); + const { subdomains } = this.options; + + // eslint-disable-next-line no-underscore-dangle + const url = this._url + .replace('{subdomain}', subdomains[(coords.x + coords.y) % subdomains.length]) + .replace('{quad_key}', quadkey); + + return url; + }, + toQuadKey, +}); + interface Props { className?: string; geoJson: GeoJSON.GeoJSON | undefined; + url?: string | undefined; + previewStyle?: StyleFunction; } function GeoJsonPreview(props: Props) { const { className, geoJson, + url = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + previewStyle, } = props; const mapRef = React.useRef(); @@ -26,7 +66,11 @@ function GeoJsonPreview(props: Props) { React.useEffect( () => { if (mapContainerRef.current && !mapRef.current) { - mapRef.current = createMap(mapContainerRef.current); + mapRef.current = createMap(mapContainerRef.current, { + zoomSnap: 0, + scrollWheelZoom: false, + zoomControl: false, + }); } if (mapRef.current) { @@ -36,11 +80,19 @@ function GeoJsonPreview(props: Props) { 1, ); - const layer = tileLayer( - 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + const finalUrl = url; + const quadKeyUrl = finalUrl.indexOf('{quad_key}') !== -1; + const Layer = quadKeyUrl + ? BingTileLayer + : TileLayer; + + const layer = new Layer( + finalUrl, { - attribution: '© OpenStreetMap', - subdomains: ['a', 'b', 'c'], + // NOTE: we have a limit of 22 + maxZoom: 22, + // attribution: '', + // subdomains: ['a', 'b', 'c'], }, ); @@ -55,7 +107,7 @@ function GeoJsonPreview(props: Props) { } }; }, - [], + [url], ); React.useEffect( @@ -69,10 +121,10 @@ function GeoJsonPreview(props: Props) { return undefined; } - const newGeoJson = geoJSON(); + const newGeoJson = geoJSON(geoJson, { + style: previewStyle, + }); newGeoJson.addTo(map); - - newGeoJson.addData(geoJson); const bounds = newGeoJson.getBounds(); if (bounds.isValid()) { @@ -84,7 +136,12 @@ function GeoJsonPreview(props: Props) { newGeoJson.remove(); }; }, - [geoJson], + // NOTE: adding url as dependency as url will re-create the map + [ + geoJson, + url, + previewStyle, + ], ); return ( diff --git a/manager-dashboard/app/components/Heading/index.tsx b/manager-dashboard/app/components/Heading/index.tsx new file mode 100644 index 000000000..4387ed17e --- /dev/null +++ b/manager-dashboard/app/components/Heading/index.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { _cs } from '@togglecorp/fujs'; + +import styles from './styles.css'; + +type HeadingLevel = 1 | 2 | 3 | 4 | 5; + +const headingLevelToClassName: Record = { + 1: styles.level1, + 2: styles.level2, + 3: styles.level3, + 4: styles.level4, + 5: styles.level5, +}; + +export interface Props { + className?: string; + level?: HeadingLevel; + children: React.ReactNode; +} + +function Heading(props: Props) { + const { + className, + level = 3, + children, + } = props; + + const levelStyle = headingLevelToClassName[level]; + const HeadingTag = `h${level}` as React.ElementType; + + return ( + + {children} + + ); +} + +export default Heading; diff --git a/manager-dashboard/app/components/Heading/styles.css b/manager-dashboard/app/components/Heading/styles.css new file mode 100644 index 000000000..81a4bf68b --- /dev/null +++ b/manager-dashboard/app/components/Heading/styles.css @@ -0,0 +1,33 @@ +.heading { + --font-size: var(--font-size-extra-large); + --line-height: var(--line-height-snug); + display: flex; + margin: 0; + font-size: var(--font-size); + font-weight: var(--font-weight-semibold); + + &.level1 { + --font-size: var(--font-size-super-large); + --line-height: var(--line-height-none); + } + + &.level2 { + --font-size: var(--font-size-extra-large); + --line-height: var(--line-height-tight); + } + + &.level3 { + --font-size: var(--font-size-large); + --line-height: var(--line-height-snug); + } + + &.level4 { + --font-size: var(--font-size-medium); + --line-height: var(--line-height-normal); + } + + &.level5 { + --font-size: var(--font-size-small); + --line-height: var(--line-height-normal); + } +} diff --git a/manager-dashboard/app/components/InputSection/index.tsx b/manager-dashboard/app/components/InputSection/index.tsx index dd63e0fd4..56f874ed8 100644 --- a/manager-dashboard/app/components/InputSection/index.tsx +++ b/manager-dashboard/app/components/InputSection/index.tsx @@ -4,9 +4,11 @@ import { _cs } from '@togglecorp/fujs'; import styles from './styles.css'; interface Props { + actions?: React.ReactNode; className?: string; heading?: React.ReactNode; children?: React.ReactNode; + contentClassName?: string; } function InputSection(props: Props) { @@ -14,16 +16,23 @@ function InputSection(props: Props) { className, heading, children, + contentClassName, + actions, } = props; return (
-

+

{heading}

+ {actions && ( +
+ {actions} +
+ )}
-
+
{children}
diff --git a/manager-dashboard/app/components/InputSection/styles.css b/manager-dashboard/app/components/InputSection/styles.css index ee278d18f..0c0012c77 100644 --- a/manager-dashboard/app/components/InputSection/styles.css +++ b/manager-dashboard/app/components/InputSection/styles.css @@ -1,18 +1,32 @@ .input-section { display: flex; flex-direction: column; - gap: var(--spacing-medium); + gap: var(--spacing-small); .header { + display: flex; + gap: var(--spacing-medium); + align-items: flex-end; padding: 0; + + .heading { + flex-grow: 1; + } + + .actions { + display: flex; + gap: var(--spacing-small); + align-items: flex-end; + } } .content { display: flex; flex-direction: column; - border-radius: var(--radius-card); + border-radius: var(--radius-card-border); + gap: var(--spacing-extra-large); background-color: var(--color-foreground); padding: var(--spacing-large); - gap: var(--spacing-extra-large); + min-height: 14rem; } } diff --git a/manager-dashboard/app/components/MarkdownEditor/index.tsx b/manager-dashboard/app/components/MarkdownEditor/index.tsx new file mode 100644 index 000000000..684ef68e8 --- /dev/null +++ b/manager-dashboard/app/components/MarkdownEditor/index.tsx @@ -0,0 +1,100 @@ +import React, { useCallback } from 'react'; +import Markdown from 'react-mde'; + +import InputContainer, { Props as InputContainerProps } from '../InputContainer'; +import MarkdownPreview from '../MarkdownPreview'; + +import styles from './styles.css'; + +interface MarkdownEditorProps { + name: NAME; + className?: string; + readOnly?: boolean; + disabled?: boolean; + value: string | null | undefined; + onChange?:(newVal: string | undefined, name: NAME) => void; +} + +export type Props = Omit & MarkdownEditorProps; + +function MarkdownEditor(props: Props) { + const { + name, + value, + onChange, + actions, + actionsContainerClassName, + className, + disabled, + error, + errorContainerClassName, + hint, + hintContainerClassName, + icons, + iconsContainerClassName, + inputSectionClassName, + label, + labelContainerClassName, + readOnly, + } = props; + + const [selectedTab, setSelectedTab] = React.useState<'write' | 'preview'>('write'); + const handleValueChange = useCallback( + (newVal) => { + if (!disabled && !readOnly && onChange) { + onChange(newVal, name); + } + }, + [name, onChange, disabled, readOnly], + ); + + const generateMarkdownPreview = useCallback((markdown: string | undefined) => ( + Promise.resolve( + , + ) + ), []); + + return ( + + ) : ( + + )} + /> + ); +} + +export default MarkdownEditor; diff --git a/manager-dashboard/app/components/MarkdownEditor/styles.css b/manager-dashboard/app/components/MarkdownEditor/styles.css new file mode 100644 index 000000000..617a4965f --- /dev/null +++ b/manager-dashboard/app/components/MarkdownEditor/styles.css @@ -0,0 +1,12 @@ +.react-mde { + border: 0; + border-radius: 0; +} + +.text-area { + background-color: transparent; +} + +.toolbar { + background-color: transparent; +} diff --git a/manager-dashboard/app/components/MarkdownPreview/index.tsx b/manager-dashboard/app/components/MarkdownPreview/index.tsx new file mode 100644 index 000000000..efb2bf9dd --- /dev/null +++ b/manager-dashboard/app/components/MarkdownPreview/index.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import MarkdownView, { MarkdownViewProps } from 'react-showdown'; + +export const markdownOptions: MarkdownViewProps['options'] = { + simpleLineBreaks: true, + headerLevelStart: 3, + simplifiedAutoLink: true, + openLinksInNewWindow: true, + backslashEscapesHTMLTags: true, + literalMidWordUnderscores: true, + strikethrough: true, + tables: true, + tasklists: true, +}; + +export default function MarkdownPreview(props: MarkdownViewProps) { + const { + options: markdownOptionsFromProps, + ...otherProps + } = props; + return ( + + ); +} diff --git a/manager-dashboard/app/components/MobilePreview/index.tsx b/manager-dashboard/app/components/MobilePreview/index.tsx new file mode 100644 index 000000000..7a2e1da65 --- /dev/null +++ b/manager-dashboard/app/components/MobilePreview/index.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { IoArrowBack, IoInformationCircleOutline } from 'react-icons/io5'; +import { _cs } from '@togglecorp/fujs'; + +import Heading from '#components/Heading'; + +import styles from './styles.css'; + +interface Props { + className?: string; + headingLabel?: React.ReactNode; + heading?: React.ReactNode; + actions?: React.ReactNode; + children?: React.ReactNode; + popupTitle?: React.ReactNode; + popupDescription?: React.ReactNode; + popupIcons?: React.ReactNode; + contentClassName?: string; + popupClassName?: string; + popupVerticalPosition?: 'top' | 'center'; +} + +function MobilePreview(props: Props) { + const { + className, + headingLabel, + heading, + actions, + children, + popupTitle, + popupDescription, + popupIcons, + contentClassName, + popupClassName, + popupVerticalPosition, + } = props; + + return ( +
+
+
+ +
+ +
+ {headingLabel} +
+
+ {heading} +
+
+
+ {actions} + +
+
+
+ {(popupTitle || popupDescription || popupIcons) && ( +
+
+
+ {popupTitle} +
+
+ {popupDescription} +
+
+
+ {popupIcons} +
+
+ )} +
+ {children} +
+
+
+ ); +} + +export default MobilePreview; diff --git a/manager-dashboard/app/components/MobilePreview/styles.css b/manager-dashboard/app/components/MobilePreview/styles.css new file mode 100644 index 000000000..dc57ad3b5 --- /dev/null +++ b/manager-dashboard/app/components/MobilePreview/styles.css @@ -0,0 +1,91 @@ +.mobile-preview { + display: flex; + flex-direction: column; + border: var(--width-separator-mobile-preview) solid var(--color-black); + border-radius: var(--radius-card-border); + background-color: var(--color-black); + padding: var(--spacing-small) 0; + width: calc(var(--width-mobile-preview) + var(--width-separator-mobile-preview)); + height: var(--height-mobile-preview); + color: var(--color-text-on-dark); + + .header { + display: flex; + flex-shrink: 0; + background-color: var(--color-primary); + padding: var(--spacing-medium); + gap: var(--spacing-medium); + + .heading { + display: flex; + flex-direction: column; + flex-grow: 1; + text-align: center; + + .label { + font-weight: var(--font-weight-medium); + } + } + + .icons { + .back-icon { + font-size: var(--font-size-super-large); + } + } + + .actions { + flex-shrink: 0; + + .info-icon { + font-size: var(--font-size-extra-large); + } + } + } + + .content-container { + display: flex; + position: relative; + flex-direction: column; + flex-grow: 1; + z-index: 0; + background-color: var(--color-primary); + padding: var(--spacing-medium); + overflow: auto; + + .content { + flex-grow: 1; + overflow: auto; + } + + .popup { + display: flex; + position: absolute; + top: var(--spacing-medium); + left: var(--spacing-small); + z-index: 1; + border-radius: var(--radius-card-border); + background-color: var(--color-foreground); + padding: var(--spacing-small) var(--spacing-medium); + width: calc(100% - 2 * var(--spacing-small)); + color: var(--color-text); + + &.vertically-centered { + top: 50%; + transform: translateY(-50%); + } + + .details { + flex-grow: 1; + + .popup-title { + font-weight: var(--font-weight-bold); + } + } + + .icons { + flex-shrink: 0; + font-size: var(--font-size-super-large); + } + } + } +} diff --git a/manager-dashboard/app/components/NonFieldError/index.tsx b/manager-dashboard/app/components/NonFieldError/index.tsx new file mode 100644 index 000000000..aa91856d3 --- /dev/null +++ b/manager-dashboard/app/components/NonFieldError/index.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { _cs, isNotDefined } from '@togglecorp/fujs'; +import { + Error, + nonFieldError, + getErrorObject, +} from '@togglecorp/toggle-form'; + +import styles from './styles.css'; + +interface Props { + className?: string; + error: Error; +} + +function NonFieldError(props: Props) { + const { + className, + error, + } = props; + + if (!error) { + return null; + } + + const errorMessage = getErrorObject(error)?.[nonFieldError]; + + if (isNotDefined(errorMessage)) { + return null; + } + + return ( +
+ {errorMessage} +
+ ); +} + +export default NonFieldError; diff --git a/manager-dashboard/app/components/NonFieldError/styles.css b/manager-dashboard/app/components/NonFieldError/styles.css new file mode 100644 index 000000000..cc6d8bdd4 --- /dev/null +++ b/manager-dashboard/app/components/NonFieldError/styles.css @@ -0,0 +1,3 @@ +.non-field-error { + color: var(--color-danger); +} diff --git a/manager-dashboard/app/components/PopupButton/index.tsx b/manager-dashboard/app/components/PopupButton/index.tsx new file mode 100644 index 000000000..a18359990 --- /dev/null +++ b/manager-dashboard/app/components/PopupButton/index.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { IoIosArrowDown, IoIosArrowUp } from 'react-icons/io'; +import { _cs } from '@togglecorp/fujs'; + +import Button, { ButtonProps } from '#components/Button'; +import useBlurEffect from '#hooks/useBlurEffect'; +import Popup from '#components/Popup'; + +import styles from './styles.css'; + +export interface PopupButtonProps extends Omit, 'label'> { + popupClassName?: string; + popupContentClassName?: string; + label: React.ReactNode; + componentRef?: React.MutableRefObject<{ + setPopupVisibility: React.Dispatch>; + } | null>; + persistent?: boolean; + arrowHidden?: boolean; + defaultShown?: boolean; +} + +function PopupButton(props: PopupButtonProps) { + const { + popupClassName, + popupContentClassName, + children, + label, + name, + actions, + componentRef, + arrowHidden, + persistent = false, + defaultShown, + ...otherProps + } = props; + + const buttonRef = React.useRef(null); + const popupRef = React.useRef(null); + + const [popupShown, setPopupShown] = React.useState(defaultShown ?? false); + + React.useEffect( + () => { + if (componentRef) { + componentRef.current = { + setPopupVisibility: setPopupShown, + }; + } + }, + [componentRef], + ); + + useBlurEffect( + popupShown && !persistent, + setPopupShown, + popupRef, + buttonRef, + ); + + const handleShowPopup = React.useCallback( + () => { + setPopupShown((prevState) => !prevState); + }, + [], + ); + + return ( + <> + + {popupShown && ( + + {children} + + )} + + ); +} + +export default PopupButton; diff --git a/manager-dashboard/app/components/PopupButton/styles.css b/manager-dashboard/app/components/PopupButton/styles.css new file mode 100644 index 000000000..235f00864 --- /dev/null +++ b/manager-dashboard/app/components/PopupButton/styles.css @@ -0,0 +1,10 @@ +.popup { + padding-top: calc(var(--tui-spacing-large) - var(--tui-spacing-medium)); + padding-bottom: calc(var(--tui-spacing-large) - var(--tui-spacing-medium)); + + .popup-content { + display: flex; + flex-direction: column; + max-width: max(50vw, 300px); + } +} \ No newline at end of file diff --git a/manager-dashboard/app/components/Preview/index.tsx b/manager-dashboard/app/components/Preview/index.tsx new file mode 100644 index 000000000..897104a0a --- /dev/null +++ b/manager-dashboard/app/components/Preview/index.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { _cs } from '@togglecorp/fujs'; + +import styles from './styles.css'; + +interface PreviewProps { + file: File | null | undefined; + className?: string; +} + +function Preview(props: PreviewProps) { + const { + file, + className, + } = props; + + const isPreviewable = file?.name?.match(/.(jpg|jpeg|png|gif)$/i) ?? false; + const [imageUrl, setImageUrl] = React.useState(); + + React.useEffect(() => { + if (!file) { + return undefined; + } + + // FIXME: use async methods + const fileReader = new FileReader(); + + const handleFileLoad = () => { + setImageUrl(String(fileReader.result) ?? undefined); + }; + + fileReader.addEventListener('load', handleFileLoad); + fileReader.readAsDataURL(file); + + return () => { + fileReader.removeEventListener('load', handleFileLoad); + }; + }, [file]); + + if (!isPreviewable) { + return ( +
+ Preview not available +
+ ); + } + + return ( + {file?.name} + ); +} + +export default Preview; diff --git a/manager-dashboard/app/components/Preview/styles.css b/manager-dashboard/app/components/Preview/styles.css new file mode 100644 index 000000000..08ae788b6 --- /dev/null +++ b/manager-dashboard/app/components/Preview/styles.css @@ -0,0 +1,21 @@ +.preview { + background-color: var(--color-input-background); + width: 100%; + max-width: 30rem; + height: 20rem; + object-fit: contain; + object-position: center center; +} + +.no-preview { + display: flex; + align-items: center; + justify-content: center; + background-color: var(--color-background); + padding: var(--spacing-medium); + width: 100%; + max-width: 30rem; + height: 10rem; + color: var(--color-text-watermark); + font-size: var(--font-size-large); +} \ No newline at end of file diff --git a/manager-dashboard/app/components/SelectInput/SearchSelectInput.tsx b/manager-dashboard/app/components/SelectInput/SearchSelectInput.tsx index 70347658f..edb93e1f5 100644 --- a/manager-dashboard/app/components/SelectInput/SearchSelectInput.tsx +++ b/manager-dashboard/app/components/SelectInput/SearchSelectInput.tsx @@ -37,7 +37,6 @@ type OptionKey = string | number; export type SearchSelectInputProps< T extends OptionKey, K, - // eslint-disable-next-line @typescript-eslint/ban-types O extends object, P extends Def, OMISSION extends string, @@ -87,7 +86,6 @@ const emptyList: unknown[] = []; function SearchSelectInput< T extends OptionKey, K extends string, - // eslint-disable-next-line @typescript-eslint/ban-types O extends object, P extends Def, >( diff --git a/manager-dashboard/app/components/SelectInput/index.tsx b/manager-dashboard/app/components/SelectInput/index.tsx index 0290ec96d..d5998c228 100644 --- a/manager-dashboard/app/components/SelectInput/index.tsx +++ b/manager-dashboard/app/components/SelectInput/index.tsx @@ -11,12 +11,10 @@ type Def = { containerClassName?: string }; export type SelectInputProps< T extends OptionKey, K extends string, - // eslint-disable-next-line @typescript-eslint/ban-types O extends object, P extends Def, > = SearchSelectInputProps; -// eslint-disable-next-line @typescript-eslint/ban-types function SelectInput( props: SelectInputProps, ) { diff --git a/manager-dashboard/app/components/SelectInputContainer/index.tsx b/manager-dashboard/app/components/SelectInputContainer/index.tsx index 9dd565627..794aab80b 100644 --- a/manager-dashboard/app/components/SelectInputContainer/index.tsx +++ b/manager-dashboard/app/components/SelectInputContainer/index.tsx @@ -92,7 +92,7 @@ export type SelectInputContainerProps< const emptyList: unknown[] = []; -// eslint-disable-next-line @typescript-eslint/ban-types, max-len +// eslint-disable-next-line max-len function SelectInputContainer( props: SelectInputContainerProps, ) { diff --git a/manager-dashboard/app/components/Tabs/index.tsx b/manager-dashboard/app/components/Tabs/index.tsx deleted file mode 100644 index 7d31b31e1..000000000 --- a/manager-dashboard/app/components/Tabs/index.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import React from 'react'; -import { _cs } from '@togglecorp/fujs'; -import RawButton, { Props as RawButtonProps } from '../RawButton'; - -import styles from './styles.css'; - -type TabKey = string; - -export interface TabContextProps { - activeTab: T; - setActiveTab: (key: T) => void; -} - -const TabContext = React.createContext>({ - activeTab: undefined, - setActiveTab: () => { - // eslint-disable-next-line no-console - console.warn('setActiveTab called before it was initialized'); - }, -}); - -export interface TabProps extends Omit, 'onClick'>{ - name: T; -} - -export function Tab(props: TabProps) { - const { - activeTab, - setActiveTab, - } = React.useContext(TabContext); - - const { - className, - name, - ...otherProps - } = props; - - const isActive = name === activeTab; - - return ( - - ); -} - -export interface TabListProps extends React.HTMLProps { - children: React.ReactNode; - className?: string; -} - -export function TabList(props: TabListProps) { - const { - children, - className, - ...otherProps - } = props; - - return ( -
- { children } -
- ); -} - -export interface TabPanelProps extends React.HTMLProps { - name: TabKey; - elementRef?: React.Ref; -} - -export function TabPanel(props: TabPanelProps) { - const { activeTab } = React.useContext(TabContext); - - const { - name, - elementRef, - ...otherProps - } = props; - - if (name !== activeTab) { - return null; - } - - return ( -
- ); -} - -export interface Props { - children: React.ReactNode; - value: T; - onChange: (key: T) => void; -} - -export function Tabs(props: Props) { - const { - children, - value, - onChange, - } = props; - - const contextValue = React.useMemo(() => ({ - // Note: following cast is required since we do not have any other method - // to provide template in the context type - activeTab: value as unknown as T, - setActiveTab: onChange as unknown as (key: T) => void, - }), [value, onChange]); - - return ( - - { children } - - ); -} - -export default Tabs; diff --git a/manager-dashboard/app/components/Tabs/styles.css b/manager-dashboard/app/components/Tabs/styles.css deleted file mode 100644 index 947908cdd..000000000 --- a/manager-dashboard/app/components/Tabs/styles.css +++ /dev/null @@ -1,13 +0,0 @@ -.tab { - text-align: left; - - &.active { - color: var(--color-accent); - } -} - -.tab-list { - display: flex; - gap: var(--spacing-large); - padding: var(--spacing-medium); -} diff --git a/manager-dashboard/app/components/TileServerInput/index.tsx b/manager-dashboard/app/components/TileServerInput/index.tsx index 5038a3c8f..ef5a7a566 100644 --- a/manager-dashboard/app/components/TileServerInput/index.tsx +++ b/manager-dashboard/app/components/TileServerInput/index.tsx @@ -7,7 +7,8 @@ import { SetValueArg, ObjectSchema, requiredStringCondition, - requiredCondition, + addCondition, + nullValue, } from '@togglecorp/toggle-form'; import TextInput from '#components/TextInput'; @@ -30,7 +31,7 @@ export const TILE_SERVER_BING = 'bing'; const TILE_SERVER_MAPBOX = 'mapbox'; const TILE_SERVER_MAXAR_STANDARD = 'maxar_standard'; const TILE_SERVER_MAXAR_PREMIUM = 'maxar_premium'; -const TILE_SERVER_ESRI = 'esri'; +export const TILE_SERVER_ESRI = 'esri'; const TILE_SERVER_ESRI_BETA = 'esri_beta'; const TILE_SERVER_CUSTOM = 'custom'; @@ -77,26 +78,53 @@ function imageryUrlCondition(value: string | null | undefined) { return 'Imagery url must contain {x}, {y} (or {-y}) & {z} placeholders or {quad_key} placeholder.'; } +const MD_TEXT_MAX_LENGTH = 1000; + type TileServerInputType = PartialForm; type TileServerSchema = ObjectSchema, unknown>; type TileServerFields = ReturnType; export function tileServerFieldsSchema(value: TileServerInputType | undefined): TileServerFields { - const basicFields: TileServerFields = { - name: [requiredStringCondition, getNoMoreThanNCharacterCondition(1000)], - credits: [requiredStringCondition, getNoMoreThanNCharacterCondition(1000)], + let basicFields: TileServerFields = { + name: { + required: true, + requiredValidation: requiredStringCondition, + validations: [getNoMoreThanNCharacterCondition(MD_TEXT_MAX_LENGTH)], + }, + credits: { + required: true, + requiredValidation: requiredStringCondition, + validations: [getNoMoreThanNCharacterCondition(MD_TEXT_MAX_LENGTH)], + }, }; - - if (value?.name === TILE_SERVER_CUSTOM) { - return { - ...basicFields, - url: [ - requiredStringCondition, - imageryUrlCondition, - getNoMoreThanNCharacterCondition(1000), - ], - wmtsLayerName: [requiredCondition, getNoMoreThanNCharacterCondition(1000)], - }; - } + basicFields = addCondition( + basicFields, + value, + ['name'], + ['url', 'wmtsLayerName'], + (tileValues) => { + if (tileValues?.name === TILE_SERVER_CUSTOM) { + return { + url: { + required: true, + requiredValidation: requiredStringCondition, + validations: [ + getNoMoreThanNCharacterCondition(MD_TEXT_MAX_LENGTH), + imageryUrlCondition, + ], + }, + wmtsLayerName: { + required: true, + requiredValidation: requiredStringCondition, + validations: [getNoMoreThanNCharacterCondition(MD_TEXT_MAX_LENGTH)], + }, + }; + } + return { + url: { forceValue: nullValue }, + wmtsLayerName: { forceValue: nullValue }, + }; + }, + ); return basicFields; } diff --git a/manager-dashboard/app/resources/icons/1_Tap_Black.png b/manager-dashboard/app/resources/icons/1_Tap_Black.png new file mode 100644 index 000000000..471cc49c6 Binary files /dev/null and b/manager-dashboard/app/resources/icons/1_Tap_Black.png differ diff --git a/manager-dashboard/app/resources/icons/2_Tap_Black.png b/manager-dashboard/app/resources/icons/2_Tap_Black.png new file mode 100644 index 000000000..689c6fcb0 Binary files /dev/null and b/manager-dashboard/app/resources/icons/2_Tap_Black.png differ diff --git a/manager-dashboard/app/resources/icons/3_Tap_Black.png b/manager-dashboard/app/resources/icons/3_Tap_Black.png new file mode 100644 index 000000000..19b33d3ab Binary files /dev/null and b/manager-dashboard/app/resources/icons/3_Tap_Black.png differ diff --git a/manager-dashboard/app/resources/icons/swipeleft_icon_black.png b/manager-dashboard/app/resources/icons/swipeleft_icon_black.png new file mode 100644 index 000000000..c6286076a Binary files /dev/null and b/manager-dashboard/app/resources/icons/swipeleft_icon_black.png differ diff --git a/manager-dashboard/app/resources/icons/tap_icon.png b/manager-dashboard/app/resources/icons/tap_icon.png new file mode 100644 index 000000000..d332bc489 Binary files /dev/null and b/manager-dashboard/app/resources/icons/tap_icon.png differ diff --git a/manager-dashboard/app/resources/icons/tap_icon_angular.png b/manager-dashboard/app/resources/icons/tap_icon_angular.png new file mode 100644 index 000000000..a925155c6 Binary files /dev/null and b/manager-dashboard/app/resources/icons/tap_icon_angular.png differ diff --git a/manager-dashboard/app/utils/common.ts b/manager-dashboard/app/utils/common.ts deleted file mode 100644 index 90ad219b7..000000000 --- a/manager-dashboard/app/utils/common.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { isDefined } from '@togglecorp/fujs'; - -export function valueSelector(item: { value: T }) { - return item.value; -} - -export function labelSelector(item: { label: T }) { - return item.label; -} - -export function getNoMoreThanNCharacterCondition(maxCharacters: number) { - return (value: string | undefined) => { - if (!isDefined(value) || value.length <= maxCharacters) { - return undefined; - } - - return `Max ${maxCharacters} characters allowed`; - }; -} - -export type ProjectInputType = 'aoi_file' | 'link' | 'TMId'; -export type ProjectStatus = 'private_active' | 'private_inactive' | 'active' | 'inactive' | 'finished' | 'archived' | 'tutorial'; -export const PROJECT_TYPE_BUILD_AREA = 1; -export const PROJECT_TYPE_FOOTPRINT = 2; -export const PROJECT_TYPE_CHANGE_DETECTION = 3; -export const PROJECT_TYPE_COMPLETENESS = 4; - -export type ProjectType = 1 | 2 | 3 | 4; - -export const projectTypeLabelMap: { - [key in ProjectType]: string -} = { - [PROJECT_TYPE_BUILD_AREA]: 'Build Area', - [PROJECT_TYPE_FOOTPRINT]: 'Footprint', - [PROJECT_TYPE_CHANGE_DETECTION]: 'Change Detection', - [PROJECT_TYPE_COMPLETENESS]: 'Completeness', -}; diff --git a/manager-dashboard/app/utils/common.tsx b/manager-dashboard/app/utils/common.tsx new file mode 100644 index 000000000..dfd624ecd --- /dev/null +++ b/manager-dashboard/app/utils/common.tsx @@ -0,0 +1,302 @@ +import React from 'react'; +import { + IoAddOutline, + IoAlertOutline, + IoBanOutline, + IoCheckmarkOutline, + IoCloseOutline, + IoEggOutline, + IoEllipseOutline, + IoFlagOutline, + IoHandLeftOutline, + IoHandRightOutline, + IoHappyOutline, + IoHeartOutline, + IoHelpOutline, + IoInformationOutline, + IoPrismOutline, + IoRefreshOutline, + IoRemoveOutline, + IoSadOutline, + IoSearchOutline, + IoShapesOutline, + IoSquareOutline, + IoStarOutline, + IoThumbsDownOutline, + IoThumbsUpOutline, + IoTriangleOutline, + IoWarningOutline, +} from 'react-icons/io5'; +import { isDefined, listToMap } from '@togglecorp/fujs'; +import { IconType } from 'react-icons'; + +import oneTapIcon from '#resources/icons/1_Tap_Black.png'; +import twoTapIcon from '#resources/icons/2_Tap_Black.png'; +import threeTapIcon from '#resources/icons/3_Tap_Black.png'; +import tapIcon from '#resources/icons/tap_icon.png'; +import angularTapIcon from '#resources/icons/tap_icon_angular.png'; +import swipeIcon from '#resources/icons/swipeleft_icon_black.png'; + +export function valueSelector(item: { value: T }) { + return item.value; +} + +export function labelSelector(item: { label: T }) { + return item.label; +} + +export function keySelector(item: { key: T }) { + return item.key; +} + +export function getNoMoreThanNCharacterCondition(maxCharacters: number) { + return (value: string | undefined) => { + if (!isDefined(value) || value.length <= maxCharacters) { + return undefined; + } + + return `Max ${maxCharacters} characters allowed`; + }; +} + +export type ProjectInputType = 'aoi_file' | 'link' | 'TMId'; +export type ProjectStatus = 'private_active' | 'private_inactive' | 'active' | 'inactive' | 'finished' | 'archived' | 'tutorial'; +export const PROJECT_TYPE_BUILD_AREA = 1; +export const PROJECT_TYPE_FOOTPRINT = 2; +export const PROJECT_TYPE_CHANGE_DETECTION = 3; +export const PROJECT_TYPE_COMPLETENESS = 4; + +export type ProjectType = 1 | 2 | 3 | 4; + +export const projectTypeLabelMap: { + [key in ProjectType]: string +} = { + [PROJECT_TYPE_BUILD_AREA]: 'Build Area', + [PROJECT_TYPE_FOOTPRINT]: 'Footprint', + [PROJECT_TYPE_CHANGE_DETECTION]: 'Change Detection', + [PROJECT_TYPE_COMPLETENESS]: 'Completeness', +}; + +export type IconKey = 'add-outline' + | 'alert-outline' + | 'ban-outline' + | 'check' + | 'close-outline' + | 'egg-outline' + | 'ellipse-outline' + | 'flag-outline' + | 'hand-left-outline' + | 'hand-right-outline' + | 'happy-outline' + | 'heart-outline' + | 'help-outline' + | 'information-outline' + | 'prism-outline' + | 'refresh-outline' + | 'remove-outline' + | 'sad-outline' + | 'search-outline' + | 'shapes-outline' + | 'square-outline' + | 'star-outline' + | 'thumbs-down-outline' + | 'thumbs-up-outline' + | 'triangle-outline' + | 'warning-outline' + | 'general-tap' + | 'tap' + | 'tap-1' + | 'tap-2' + | 'tap-3' + | 'swipe-left'; + +export interface IconItem { + key: IconKey; + label: string; + component: IconType; +} + +function getPngIcon(src: string, alt: string) { + const element = () => ( + {alt} + ); + + return element; +} + +export const iconList: IconItem[] = [ + { + key: 'add-outline', + label: 'Add', + component: IoAddOutline, + }, + { + key: 'alert-outline', + label: 'Alert', + component: IoAlertOutline, + }, + { + key: 'ban-outline', + label: 'Ban', + component: IoBanOutline, + }, + { + key: 'check', + label: 'Check', + component: IoCheckmarkOutline, + }, + { + key: 'close-outline', + label: 'Close', + component: IoCloseOutline, + }, + { + key: 'egg-outline', + label: 'Egg', + component: IoEggOutline, + }, + { + key: 'ellipse-outline', + label: 'Ellipse', + component: IoEllipseOutline, + }, + { + key: 'flag-outline', + label: 'Flag', + component: IoFlagOutline, + }, + { + key: 'hand-left-outline', + label: 'Hand Left', + component: IoHandLeftOutline, + }, + { + key: 'hand-right-outline', + label: 'Hand Right', + component: IoHandRightOutline, + }, + { + key: 'happy-outline', + label: 'Happy', + component: IoHappyOutline, + }, + { + key: 'heart-outline', + label: 'Heart', + component: IoHeartOutline, + }, + { + key: 'help-outline', + label: 'Help', + component: IoHelpOutline, + }, + { + key: 'information-outline', + label: 'Information', + component: IoInformationOutline, + }, + { + key: 'prism-outline', + label: 'Prism', + component: IoPrismOutline, + }, + { + key: 'refresh-outline', + label: 'Refresh', + component: IoRefreshOutline, + }, + { + key: 'remove-outline', + label: 'Remove', + component: IoRemoveOutline, + }, + { + key: 'sad-outline', + label: 'Sad', + component: IoSadOutline, + }, + { + key: 'search-outline', + label: 'Search', + component: IoSearchOutline, + }, + { + key: 'shapes-outline', + label: 'Shapes', + component: IoShapesOutline, + }, + { + key: 'square-outline', + label: 'Square', + component: IoSquareOutline, + }, + { + key: 'star-outline', + label: 'Star', + component: IoStarOutline, + }, + { + key: 'thumbs-down-outline', + label: 'Thumbs Down', + component: IoThumbsDownOutline, + }, + { + key: 'thumbs-up-outline', + label: 'Thumbs Up', + component: IoThumbsUpOutline, + }, + { + key: 'triangle-outline', + label: 'Triangle', + component: IoTriangleOutline, + }, + { + key: 'warning-outline', + label: 'Warning', + component: IoWarningOutline, + }, + { + key: 'general-tap', + label: 'General Tap', + component: getPngIcon(tapIcon, 'general tap'), + }, + { + key: 'tap', + label: 'Tap', + component: getPngIcon(angularTapIcon, 'tap'), + }, + { + key: 'tap-1', + label: '1-Tap', + component: getPngIcon(oneTapIcon, 'one tap'), + }, + { + key: 'tap-2', + label: '2-Tap', + component: getPngIcon(twoTapIcon, 'two tap'), + }, + { + key: 'tap-3', + label: '3-Tap', + component: getPngIcon(threeTapIcon, 'three tap'), + }, + { + key: 'swipe-left', + label: 'Swipe Left', + component: getPngIcon(swipeIcon, 'swipe left'), + }, +]; + +export const iconMap = listToMap( + iconList, + (icon) => icon.key, + (icon) => icon.component, +); diff --git a/manager-dashboard/app/components/OrganisationFormModal/index.tsx b/manager-dashboard/app/views/Home/OrganisationFormModal/index.tsx similarity index 94% rename from manager-dashboard/app/components/OrganisationFormModal/index.tsx rename to manager-dashboard/app/views/Home/OrganisationFormModal/index.tsx index b79bdbe03..5c0b0e7f8 100644 --- a/manager-dashboard/app/components/OrganisationFormModal/index.tsx +++ b/manager-dashboard/app/views/Home/OrganisationFormModal/index.tsx @@ -18,8 +18,8 @@ import { useForm, getErrorObject, createSubmitHandler, - requiredCondition, analyzeErrors, + requiredStringCondition, } from '@togglecorp/toggle-form'; import { MdOutlinePublishedWithChanges, @@ -51,8 +51,16 @@ const MAX_CHARS_DESCRIPTION = 100; const organisationFormSchema: OrganisationFormSchema = { fields: (): OrganisationFormSchemaFields => ({ - name: [requiredCondition, getNoMoreThanNCharacterCondition(MAX_CHARS_NAME)], - description: [getNoMoreThanNCharacterCondition(MAX_CHARS_DESCRIPTION)], + name: { + required: true, + requiredValidation: requiredStringCondition, + validations: [getNoMoreThanNCharacterCondition(MAX_CHARS_NAME)], + }, + description: { + required: true, + requiredValidation: requiredStringCondition, + validations: [getNoMoreThanNCharacterCondition(MAX_CHARS_DESCRIPTION)], + }, }), }; @@ -74,7 +82,7 @@ function OrganisationFormModal(props: Props) { value, validate, setError, - } = useForm(organisationFormSchema, defaultOrganisationFormValue); + } = useForm(organisationFormSchema, { value: defaultOrganisationFormValue }); const mountedRef = useMountedRef(); const { user } = React.useContext(UserContext); diff --git a/manager-dashboard/app/components/OrganisationFormModal/styles.css b/manager-dashboard/app/views/Home/OrganisationFormModal/styles.css similarity index 100% rename from manager-dashboard/app/components/OrganisationFormModal/styles.css rename to manager-dashboard/app/views/Home/OrganisationFormModal/styles.css diff --git a/manager-dashboard/app/components/OrganisationList/index.tsx b/manager-dashboard/app/views/Home/OrganisationList/index.tsx similarity index 100% rename from manager-dashboard/app/components/OrganisationList/index.tsx rename to manager-dashboard/app/views/Home/OrganisationList/index.tsx diff --git a/manager-dashboard/app/components/OrganisationList/styles.css b/manager-dashboard/app/views/Home/OrganisationList/styles.css similarity index 100% rename from manager-dashboard/app/components/OrganisationList/styles.css rename to manager-dashboard/app/views/Home/OrganisationList/styles.css diff --git a/manager-dashboard/app/components/TutorialList/index.tsx b/manager-dashboard/app/views/Home/TutorialList/index.tsx similarity index 100% rename from manager-dashboard/app/components/TutorialList/index.tsx rename to manager-dashboard/app/views/Home/TutorialList/index.tsx diff --git a/manager-dashboard/app/components/TutorialList/styles.css b/manager-dashboard/app/views/Home/TutorialList/styles.css similarity index 100% rename from manager-dashboard/app/components/TutorialList/styles.css rename to manager-dashboard/app/views/Home/TutorialList/styles.css diff --git a/manager-dashboard/app/views/Home/index.tsx b/manager-dashboard/app/views/Home/index.tsx index ef2c69073..ad1c09bd8 100644 --- a/manager-dashboard/app/views/Home/index.tsx +++ b/manager-dashboard/app/views/Home/index.tsx @@ -12,12 +12,12 @@ import SmartLink from '#base/components/SmartLink'; import useBooleanState from '#hooks/useBooleanState'; import Button from '#components/Button'; -import OrganisationFormModal from '#components/OrganisationFormModal'; -import TutorialList from '#components/TutorialList'; -import OrganisationList from '#components/OrganisationList'; import TextInput from '#components/TextInput'; import useInputState from '#hooks/useInputState'; +import OrganisationFormModal from './OrganisationFormModal'; +import OrganisationList from './OrganisationList'; +import TutorialList from './TutorialList'; import styles from './styles.css'; interface Props { diff --git a/manager-dashboard/app/views/Login/index.tsx b/manager-dashboard/app/views/Login/index.tsx index 8dc6c5c13..f0d45f6b1 100644 --- a/manager-dashboard/app/views/Login/index.tsx +++ b/manager-dashboard/app/views/Login/index.tsx @@ -12,7 +12,7 @@ import { useForm, getErrorObject, createSubmitHandler, - internal, + nonFieldError, } from '@togglecorp/toggle-form'; import TextInput from '#components/TextInput'; @@ -33,8 +33,14 @@ type LoginFormSchema = ObjectSchema; type LoginFormSchemaFields = ReturnType const loginFormSchema: LoginFormSchema = { fields: (): LoginFormSchemaFields => ({ - email: [requiredStringCondition], - password: [requiredStringCondition], + email: { + required: true, + requiredValidation: requiredStringCondition, + }, + password: { + required: true, + requiredValidation: requiredStringCondition, + }, }), }; @@ -57,7 +63,7 @@ function Login(props: Props) { value, validate, setError, - } = useForm(loginFormSchema, defaultLoginFormValue); + } = useForm(loginFormSchema, { value: defaultLoginFormValue }); const error = getErrorObject(formError); const [pending, setPending] = React.useState(false); @@ -83,7 +89,6 @@ function Login(props: Props) { if (!mountedRef.current) { return; } - setErrorMessage(undefined); setPending(false); } catch (submissionError) { // eslint-disable-next-line no-console @@ -118,7 +123,7 @@ function Login(props: Props) { setError((prevError) => ({ ...getErrorObject(prevError), - [internal]: 'Failed to authenticate', + [nonFieldError]: 'Failed to authenticate', })); setPending(false); @@ -168,9 +173,9 @@ function Login(props: Props) { type="password" disabled={pending} /> - {error?.[internal] && ( + {error?.[nonFieldError] && (
- {error?.[internal]} + {error?.[nonFieldError]}
)}
diff --git a/manager-dashboard/app/views/Login/styles.css b/manager-dashboard/app/views/Login/styles.css index 82d1d718a..a9726a911 100644 --- a/manager-dashboard/app/views/Login/styles.css +++ b/manager-dashboard/app/views/Login/styles.css @@ -25,7 +25,7 @@ } .text { - color: var(--color-text-on-light); + color: var(--color-text-on-dark); } } diff --git a/manager-dashboard/app/views/NewProject/BasicProjectInfoForm/index.tsx b/manager-dashboard/app/views/NewProject/BasicProjectInfoForm/index.tsx index 648f5d3ef..8daa29984 100644 --- a/manager-dashboard/app/views/NewProject/BasicProjectInfoForm/index.tsx +++ b/manager-dashboard/app/views/NewProject/BasicProjectInfoForm/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import { EntriesAsList, ObjectError, SetBaseValueArg } from '@togglecorp/toggle-form'; import TextInput from '#components/TextInput'; import { generateProjectName, PartialProjectFormType } from '#views/NewProject/utils'; @@ -12,7 +12,7 @@ import styles from '#views/NewProject/styles.css'; export interface Props { className?: string; - submissionPending: boolean; + disabled: boolean; value: T; setValue: (value: SetBaseValueArg, doNotReset?: boolean) => void; setFieldValue: (...entries: EntriesAsList) => void; @@ -21,7 +21,7 @@ export interface Props { function BasicProjectInfoForm(props: Props) { const { - submissionPending, + disabled, value, setValue, setFieldValue, @@ -37,10 +37,6 @@ function BasicProjectInfoForm(props: Props) { organisationsPending, } = useProjectOptions(value?.projectType); - React.useEffect(() => { - setFieldValue(tutorialOptions?.[0]?.value, 'tutorialId'); - }, [setFieldValue, value?.projectType, tutorialOptions]); - const setFieldValueAndGenerateName = React.useCallback( (...entries: EntriesAsList) => { // NOTE: we need to use setFieldValue to set error on change @@ -61,6 +57,21 @@ function BasicProjectInfoForm(props: Props) { }, [setFieldValue, setValue], ); + + const handleTutorialOptions = useCallback( + (tutorialId: string) => { + setFieldValue(tutorialId, 'tutorialId'); + + const newTutorial = tutorialOptions.find((tutorial) => tutorial.value === tutorialId); + + setFieldValue(newTutorial?.customOptions, 'customOptions'); + }, + [ + tutorialOptions, + setFieldValue, + ], + ); + return ( <> @@ -72,7 +83,7 @@ function BasicProjectInfoForm(props: Props) { error={error?.projectTopic} label="Project Topic" hint="Enter the topic of your project (50 char max)." - disabled={submissionPending} + disabled={disabled} autoFocus /> ) { label="Project Region" hint="Enter name of your project Region (50 chars max)" error={error?.projectRegion} - disabled={submissionPending} + disabled={disabled} />
@@ -93,7 +104,7 @@ function BasicProjectInfoForm(props: Props) { label="Project Number" hint="Is this project part of a bigger campaign with multiple projects?" error={error?.projectNumber} - disabled={submissionPending} + disabled={disabled} /> ) { error={error?.requestingOrganisation} label="Requesting Organisation" hint="Which group, institution or community is requesting this project?" - disabled={submissionPending || organisationsPending} + disabled={disabled || organisationsPending} keySelector={valueSelector} labelSelector={labelSelector} /> @@ -116,7 +127,7 @@ function BasicProjectInfoForm(props: Props) { readOnly placeholder="[Project Topic] - [Project Region] ([Task Number]) [Requesting Organisation]" // error={error?.name} - disabled={submissionPending} + disabled={disabled} />
) { label="Visibility" hint="Choose either 'public' or select the team for which this project should be displayed" error={error?.visibility} - disabled={submissionPending || teamsPending} + disabled={disabled || teamsPending} /> ) { error={error?.lookFor} label="Look For" hint="What should the users look for (e.g. buildings, cars, trees)? (25 chars max)" - disabled={submissionPending} + disabled={disabled} />