@@ -517,7 +661,7 @@ function NewProject(props: Props) {
value={option}
index={index}
onChange={noOp}
- error={optionsError?.[option.value]}
+ error={customOptionsError?.[option.value]}
readOnly
/>
@@ -743,7 +887,7 @@ function NewProject(props: Props) {
value={value?.organizationId}
onChange={setFieldValue}
error={error?.organizationId}
- label="Mapillary Organization ID"
+ label="Mapillary Organization IidD"
hint="Provide a valid Mapillary organization ID to filter for images belonging to a specific organization. Empty indicates that no filter is set on organization."
disabled={submissionPending || projectTypeEmpty}
/>
diff --git a/manager-dashboard/app/views/NewProject/styles.css b/manager-dashboard/app/views/NewProject/styles.css
index cbfa76230..45aedf1bc 100644
--- a/manager-dashboard/app/views/NewProject/styles.css
+++ b/manager-dashboard/app/views/NewProject/styles.css
@@ -13,6 +13,14 @@
max-width: 70rem;
gap: var(--spacing-large);
+
+ .image-list {
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+ gap: var(--spacing-medium);
+ }
+
.custom-option-container {
display: flex;
gap: var(--spacing-large);
diff --git a/manager-dashboard/app/views/NewProject/utils.ts b/manager-dashboard/app/views/NewProject/utils.ts
index ce419e42d..e2ac2731f 100644
--- a/manager-dashboard/app/views/NewProject/utils.ts
+++ b/manager-dashboard/app/views/NewProject/utils.ts
@@ -34,6 +34,7 @@ import {
ProjectInputType,
PROJECT_TYPE_BUILD_AREA,
PROJECT_TYPE_FOOTPRINT,
+ PROJECT_TYPE_VALIDATE_IMAGE,
PROJECT_TYPE_CHANGE_DETECTION,
PROJECT_TYPE_COMPLETENESS,
PROJECT_TYPE_STREET,
@@ -68,13 +69,14 @@ export interface ProjectFormType {
projectImage: File; // image
verificationNumber: number;
groupSize: number;
+ maxTasksPerUser: number;
+
zoomLevel: number;
geometry?: GeoJSON.GeoJSON | string;
inputType?: ProjectInputType;
TMId?: string;
filter?: string;
filterText?: string;
- maxTasksPerUser: number;
tileServer: TileServer;
tileServerB?: TileServer;
customOptions?: CustomOptionsForProject;
@@ -87,6 +89,11 @@ export interface ProjectFormType {
panoOnly?: boolean;
isPano?: boolean | null;
samplingThreshold?: number;
+ images?: {
+ sourceIdentifier: string;
+ fileName: string;
+ url: string;
+ }[];
}
export const PROJECT_INPUT_TYPE_UPLOAD = 'aoi_file';
@@ -115,9 +122,11 @@ export const filterOptions = [
export type PartialProjectFormType = PartialForm<
Omit
& { projectImage?: File },
// NOTE: we do not want to change File and FeatureCollection to partials
- 'geometry' | 'projectImage' | 'value'
+ 'geometry' | 'projectImage' | 'value' | 'sourceIdentifier'
>;
+export type ImageType = NonNullable[number];
+
type ProjectFormSchema = ObjectSchema;
type ProjectFormSchemaFields = ReturnType;
@@ -127,6 +136,12 @@ type CustomOptionSchemaFields = ReturnType
type CustomOptionFormSchema = ArraySchema;
type CustomOptionFormSchemaMember = ReturnType;
+type PartialImages = NonNullable[number];
+type ImageSchema = ObjectSchema;
+type ImageSchemaFields = ReturnType
+type ImageFormSchema = ArraySchema;
+type ImageFormSchemaMember = ReturnType;
+
// FIXME: break this into multiple geometry conditions
const DEFAULT_MAX_FEATURES = 20;
// const DEFAULT_MAX_FEATURES = 10;
@@ -194,6 +209,8 @@ function validGeometryCondition(zoomLevel: number | undefined | null) {
return validGeometryConditionForZoom;
}
+export const MAX_IMAGES = 2000;
+
export const MAX_OPTIONS = 6;
export const MIN_OPTIONS = 2;
export const MAX_SUB_OPTIONS = 6;
@@ -275,49 +292,16 @@ export const projectFormSchema: ProjectFormSchema = {
lessThanOrEqualToCondition(250),
],
},
- tileServer: {
- fields: tileServerFieldsSchema,
- },
maxTasksPerUser: {
validations: [
integerCondition,
greaterThanCondition(0),
],
},
- dateRange: {
- required: false,
- },
- creatorId: {
- required: false,
- validations: [
- integerCondition,
- greaterThanCondition(0),
- ],
- },
- organizationId: {
- required: false,
- validations: [
- integerCondition,
- greaterThanCondition(0),
- ],
- },
- samplingThreshold: {
- required: false,
- validation: [
- greaterThanCondition(0),
- ],
- },
- panoOnly: {
- required: false,
- },
- isPano: {
- required: false,
- },
- randomizeOrder: {
- required: false,
- },
};
+ // Common
+
baseSchema = addCondition(
baseSchema,
value,
@@ -325,6 +309,7 @@ export const projectFormSchema: ProjectFormSchema = {
['customOptions'],
(formValues) => {
if (formValues?.projectType === PROJECT_TYPE_FOOTPRINT
+ || formValues?.projectType === PROJECT_TYPE_VALIDATE_IMAGE
|| formValues?.projectType === PROJECT_TYPE_STREET) {
return {
customOptions: {
@@ -388,8 +373,8 @@ export const projectFormSchema: ProjectFormSchema = {
const projectType = v?.projectType;
if (
projectType === PROJECT_TYPE_BUILD_AREA
- || projectType === PROJECT_TYPE_COMPLETENESS
|| projectType === PROJECT_TYPE_CHANGE_DETECTION
+ || projectType === PROJECT_TYPE_COMPLETENESS
) {
return {
zoomLevel: {
@@ -408,24 +393,6 @@ export const projectFormSchema: ProjectFormSchema = {
},
);
- baseSchema = addCondition(
- baseSchema,
- value,
- ['projectType'],
- ['inputType'],
- (v) => {
- const projectType = v?.projectType;
- if (projectType === PROJECT_TYPE_FOOTPRINT) {
- return {
- inputType: { required: true },
- };
- }
- return {
- inputType: { forceValue: nullValue },
- };
- },
- );
-
baseSchema = addCondition(
baseSchema,
value,
@@ -437,8 +404,8 @@ export const projectFormSchema: ProjectFormSchema = {
const zoomLevel = v?.zoomLevel;
if (
projectType === PROJECT_TYPE_BUILD_AREA
- || projectType === PROJECT_TYPE_COMPLETENESS
|| projectType === PROJECT_TYPE_CHANGE_DETECTION
+ || projectType === PROJECT_TYPE_COMPLETENESS
|| projectType === PROJECT_TYPE_STREET
|| (projectType === PROJECT_TYPE_FOOTPRINT && (
inputType === PROJECT_INPUT_TYPE_UPLOAD
@@ -483,6 +450,51 @@ export const projectFormSchema: ProjectFormSchema = {
},
);
+ baseSchema = addCondition(
+ baseSchema,
+ value,
+ ['projectType'],
+ ['tileServer'],
+ (v) => {
+ const projectType = v?.projectType;
+ if (
+ projectType === PROJECT_TYPE_BUILD_AREA
+ || projectType === PROJECT_TYPE_COMPLETENESS
+ || projectType === PROJECT_TYPE_CHANGE_DETECTION
+ || projectType === PROJECT_TYPE_FOOTPRINT
+ ) {
+ return {
+ tileServer: {
+ fields: tileServerFieldsSchema,
+ },
+ };
+ }
+ return {
+ tileServer: { forceValue: nullValue },
+ };
+ },
+ );
+
+ // Validate
+
+ baseSchema = addCondition(
+ baseSchema,
+ value,
+ ['projectType'],
+ ['inputType'],
+ (v) => {
+ const projectType = v?.projectType;
+ if (projectType === PROJECT_TYPE_FOOTPRINT) {
+ return {
+ inputType: { required: true },
+ };
+ }
+ return {
+ inputType: { forceValue: nullValue },
+ };
+ },
+ );
+
baseSchema = addCondition(
baseSchema,
value,
@@ -560,6 +572,108 @@ export const projectFormSchema: ProjectFormSchema = {
},
);
+ // Street
+
+ baseSchema = addCondition(
+ baseSchema,
+ value,
+ ['projectType'],
+ ['dateRange', 'creatorId', 'organizationId', 'samplingThreshold', 'panoOnly', 'isPano', 'randomizeOrder'],
+ (formValues) => {
+ if (formValues?.projectType === PROJECT_TYPE_STREET) {
+ return {
+ dateRange: {
+ required: false,
+ },
+ creatorId: {
+ required: false,
+ validations: [
+ integerCondition,
+ greaterThanCondition(0),
+ ],
+ },
+ organizationId: {
+ required: false,
+ validations: [
+ integerCondition,
+ greaterThanCondition(0),
+ ],
+ },
+ samplingThreshold: {
+ required: false,
+ validations: [
+ greaterThanCondition(0),
+ ],
+ },
+ panoOnly: {
+ required: false,
+ },
+ // FIXME: This is not used.
+ isPano: {
+ required: false,
+ },
+ randomizeOrder: {
+ required: false,
+ },
+ };
+ }
+ return {
+ dateRange: { forceValue: nullValue },
+ creatorId: { forceValue: nullValue },
+ organizationId: { forceValue: nullValue },
+ samplingThreshold: { forceValue: nullValue },
+ panoOnly: { forceValue: nullValue },
+ isPano: { forceValude: nullValue },
+ randomizeOrder: { forceValue: nullValue },
+ };
+ },
+ );
+
+ // Validate Image
+
+ baseSchema = addCondition(
+ baseSchema,
+ value,
+ ['projectType'],
+ ['images'],
+ (formValues) => {
+ // FIXME: Add "unique" constraint for sourceIdentifier and fileName
+ if (formValues?.projectType === PROJECT_TYPE_VALIDATE_IMAGE) {
+ return {
+ images: {
+ keySelector: (key) => key.sourceIdentifier,
+ validation: (values) => {
+ if (values && values.length > MAX_IMAGES) {
+ return `Too many images ${values.length}. Please do not exceed ${MAX_IMAGES} images.`;
+ }
+ return undefined;
+ },
+ member: (): ImageFormSchemaMember => ({
+ fields: (): ImageSchemaFields => ({
+ sourceIdentifier: {
+ required: true,
+ requiredValidation: requiredStringCondition,
+ },
+ fileName: {
+ required: true,
+ requiredValidation: requiredStringCondition,
+ },
+ url: {
+ required: true,
+ requiredValidation: requiredStringCondition,
+ validations: [urlCondition],
+ },
+ }),
+ }),
+ },
+ };
+ }
+ return {
+ images: { forceValue: nullValue },
+ };
+ },
+ );
+
return baseSchema;
},
};
@@ -588,6 +702,7 @@ export function getGroupSize(projectType: ProjectType | undefined) {
}
if (projectType === PROJECT_TYPE_FOOTPRINT
+ || projectType === PROJECT_TYPE_VALIDATE_IMAGE
|| projectType === PROJECT_TYPE_CHANGE_DETECTION
|| projectType === PROJECT_TYPE_STREET) {
return 25;
diff --git a/manager-dashboard/app/views/NewTutorial/ImageInput/index.tsx b/manager-dashboard/app/views/NewTutorial/ImageInput/index.tsx
new file mode 100644
index 000000000..ca10b5806
--- /dev/null
+++ b/manager-dashboard/app/views/NewTutorial/ImageInput/index.tsx
@@ -0,0 +1,135 @@
+import React, { useMemo } from 'react';
+
+import {
+ SetValueArg,
+ Error,
+ useFormObject,
+ getErrorObject,
+} from '@togglecorp/toggle-form';
+import { isNotDefined, isDefined, unique } from '@togglecorp/fujs';
+import TextInput from '#components/TextInput';
+import SelectInput from '#components/SelectInput';
+import NumberInput from '#components/NumberInput';
+
+import {
+ ImageType,
+ PartialCustomOptionsType,
+} from '../utils';
+
+import styles from './styles.css';
+
+const defaultImageValue: ImageType = {
+ sourceIdentifier: '',
+};
+
+interface Props {
+ value: ImageType;
+ onChange: (value: SetValueArg, index: number) => void | undefined;
+ index: number;
+ error: Error | undefined;
+ disabled?: boolean;
+ readOnly?: boolean;
+ customOptions: PartialCustomOptionsType | undefined;
+}
+
+export default function ImageInput(props: Props) {
+ const {
+ value,
+ onChange,
+ index,
+ error: riskyError,
+ disabled,
+ readOnly,
+ customOptions,
+ } = props;
+
+ const flattenedOptions = useMemo(
+ () => {
+ const opts = customOptions?.flatMap(
+ (option) => ([
+ {
+ key: option.value,
+ label: option.title,
+ },
+ ...(option.subOptions ?? []).map(
+ (subOption) => ({
+ key: subOption.value,
+ label: subOption.description,
+ }),
+ ),
+ ]),
+ ) ?? [];
+
+ const validOpts = opts.map(
+ (option) => {
+ if (isNotDefined(option.key)) {
+ return undefined;
+ }
+ return {
+ ...option,
+ key: option.key,
+ };
+ },
+ ).filter(isDefined);
+ return unique(
+ validOpts,
+ (option) => option.key,
+ );
+ },
+ [customOptions],
+ );
+
+ const onImageChange = useFormObject(index, onChange, defaultImageValue);
+
+ const error = getErrorObject(riskyError);
+
+ return (
+
+
+
+
+
+ option.key}
+ labelSelector={(option) => option.label ?? `Option ${option.key}`}
+ options={flattenedOptions}
+ error={error?.referenceAnswer}
+ disabled={disabled || readOnly}
+ />
+
+ );
+}
diff --git a/manager-dashboard/app/views/NewTutorial/ImageInput/styles.css b/manager-dashboard/app/views/NewTutorial/ImageInput/styles.css
new file mode 100644
index 000000000..a6e6f1707
--- /dev/null
+++ b/manager-dashboard/app/views/NewTutorial/ImageInput/styles.css
@@ -0,0 +1,5 @@
+.image-input {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-medium);
+}
diff --git a/manager-dashboard/app/views/NewTutorial/ScenarioPageInput/FootprintGeoJsonPreview/index.tsx b/manager-dashboard/app/views/NewTutorial/ScenarioPageInput/FootprintGeoJsonPreview/index.tsx
index f381ff4f9..2ab9cbe36 100644
--- a/manager-dashboard/app/views/NewTutorial/ScenarioPageInput/FootprintGeoJsonPreview/index.tsx
+++ b/manager-dashboard/app/views/NewTutorial/ScenarioPageInput/FootprintGeoJsonPreview/index.tsx
@@ -15,7 +15,7 @@ import {
import styles from './styles.css';
// NOTE: the padding is selected wrt the size of the preview
-const footprintGeojsonPadding = [140, 140];
+const footprintGeojsonPadding: [number, number] = [140, 140];
interface Props {
className?: string;
diff --git a/manager-dashboard/app/views/NewTutorial/ScenarioPageInput/ValidateImagePreview/index.tsx b/manager-dashboard/app/views/NewTutorial/ScenarioPageInput/ValidateImagePreview/index.tsx
new file mode 100644
index 000000000..3dfa8fb98
--- /dev/null
+++ b/manager-dashboard/app/views/NewTutorial/ScenarioPageInput/ValidateImagePreview/index.tsx
@@ -0,0 +1,81 @@
+import React from 'react';
+import { _cs } from '@togglecorp/fujs';
+
+import MobilePreview from '#components/MobilePreview';
+import { IconKey, iconMap } from '#utils/common';
+
+import {
+ ImageType,
+ colorKeyToColorMap,
+ PartialCustomOptionsType,
+} from '../../utils';
+import styles from './styles.css';
+
+interface Props {
+ className?: string;
+ image?: ImageType;
+ previewPopUp?: {
+ title?: string;
+ description?: string;
+ icon?: IconKey;
+ }
+ customOptions: PartialCustomOptionsType | undefined;
+ lookFor: string | undefined;
+}
+
+export default function ValidateImagePreview(props: Props) {
+ const {
+ className,
+ previewPopUp,
+ customOptions,
+ lookFor,
+ image,
+ } = props;
+
+ const Comp = previewPopUp?.icon ? iconMap[previewPopUp.icon] : undefined;
+
+ return (
+ }
+ popupTitle={previewPopUp?.title || '{title}'}
+ popupDescription={previewPopUp?.description || '{description}'}
+ >
+
+
+ {customOptions?.map((option) => {
+ const Icon = option.icon
+ ? iconMap[option.icon]
+ : iconMap['flag-outline'];
+ return (
+
+
+ {Icon && (
+
+ )}
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/manager-dashboard/app/views/NewTutorial/ScenarioPageInput/ValidateImagePreview/styles.css b/manager-dashboard/app/views/NewTutorial/ScenarioPageInput/ValidateImagePreview/styles.css
new file mode 100644
index 000000000..5f708d4a5
--- /dev/null
+++ b/manager-dashboard/app/views/NewTutorial/ScenarioPageInput/ValidateImagePreview/styles.css
@@ -0,0 +1,36 @@
+.validate-image-preview {
+ .content {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-large);
+
+ .image-preview {
+ position: relative;
+ width: 100%;
+ height: var(--height-mobile-preview-validate-image-content);
+ }
+
+ .options {
+ display: grid;
+ flex-grow: 1;
+ grid-template-columns: 1fr 1fr 1fr;
+ grid-gap: var(--spacing-large);
+
+ .option-container {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ .option {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50%;
+ width: 2.5rem;
+ height: 2.5rem;
+ font-size: var(--font-size-extra-large);
+ }
+ }
+ }
+ }
+}
diff --git a/manager-dashboard/app/views/NewTutorial/ScenarioPageInput/index.tsx b/manager-dashboard/app/views/NewTutorial/ScenarioPageInput/index.tsx
index b309be7ff..2ba9d05fe 100644
--- a/manager-dashboard/app/views/NewTutorial/ScenarioPageInput/index.tsx
+++ b/manager-dashboard/app/views/NewTutorial/ScenarioPageInput/index.tsx
@@ -18,6 +18,7 @@ import {
PROJECT_TYPE_CHANGE_DETECTION,
PROJECT_TYPE_COMPLETENESS,
PROJECT_TYPE_STREET,
+ PROJECT_TYPE_VALIDATE_IMAGE,
} from '#utils/common';
import TextInput from '#components/TextInput';
import Heading from '#components/Heading';
@@ -25,6 +26,7 @@ import SelectInput from '#components/SelectInput';
import SegmentInput from '#components/SegmentInput';
import {
+ ImageType,
TutorialTasksGeoJSON,
FootprintGeoJSON,
BuildAreaGeoJSON,
@@ -34,6 +36,7 @@ import {
import BuildAreaGeoJsonPreview from './BuildAreaGeoJsonPreview';
import FootprintGeoJsonPreview from './FootprintGeoJsonPreview';
import ChangeDetectionGeoJsonPreview from './ChangeDetectionGeoJsonPreview';
+import ValidateImagePreview from './ValidateImagePreview';
import styles from './styles.css';
type ScenarioType = {
@@ -78,6 +81,7 @@ interface Props {
index: number,
error: Error | undefined;
geoJson: TutorialTasksGeoJSON | undefined;
+ images: ImageType[] | undefined;
projectType: ProjectType | undefined;
urlA: string | undefined;
urlB: string | undefined;
@@ -94,6 +98,7 @@ export default function ScenarioPageInput(props: Props) {
index,
error: riskyError,
geoJson: geoJsonFromProps,
+ images,
urlA,
projectType,
urlB,
@@ -171,7 +176,21 @@ export default function ScenarioPageInput(props: Props) {
[geoJsonFromProps, scenarioId],
);
- const activeSegmentInput: ScenarioSegmentType['value'] = projectType && projectType !== PROJECT_TYPE_FOOTPRINT
+ const image = React.useMemo(
+ () => {
+ if (!images) {
+ return undefined;
+ }
+ return images.find((img) => img.screen === scenarioId);
+ },
+ [images, scenarioId],
+ );
+
+ const activeSegmentInput: ScenarioSegmentType['value'] = (
+ projectType
+ && projectType !== PROJECT_TYPE_FOOTPRINT
+ && projectType !== PROJECT_TYPE_VALIDATE_IMAGE
+ )
? activeSegmentInputFromState
: 'instructions';
@@ -214,7 +233,11 @@ export default function ScenarioPageInput(props: Props) {
disabled={disabled}
/>
- {projectType && projectType !== PROJECT_TYPE_FOOTPRINT && (
+ {(
+ projectType
+ && projectType !== PROJECT_TYPE_FOOTPRINT
+ && projectType !== PROJECT_TYPE_VALIDATE_IMAGE
+ ) && (
<>