diff --git a/docs/web/docs/guides/how_to_use/form_composer/configuration/config_files.md b/docs/web/docs/guides/how_to_use/form_composer/configuration/config_files.md index 08daaf149..797593610 100644 --- a/docs/web/docs/guides/how_to_use/form_composer/configuration/config_files.md +++ b/docs/web/docs/guides/how_to_use/form_composer/configuration/config_files.md @@ -147,7 +147,7 @@ While attributes values are limited to numbers and text, these fields (at any hi _Note that, due to limitations of JSON format, HTML content needs to be converted into a single long string of text._ You can style fields with HTML-classes in `classes` attribute. You can use any bootstrap classes or our built-in classes: -- `hidden` - if you need to hide element and show it later with custom triggerm, but you do not need it be a fully hidden field (`"type": "hidden"`) +- `hidden` - if you need to hide element and show it later with custom trigger, but you do not need it be a fully hidden field (`"type": "hidden"`) - `centered` - centered horizontally TBD: Other classes and styles insertions diff --git a/docs/web/docs/guides/how_to_use/review_app/running.md b/docs/web/docs/guides/how_to_use/review_app/running.md index 5504828e6..4924a1607 100644 --- a/docs/web/docs/guides/how_to_use/review_app/running.md +++ b/docs/web/docs/guides/how_to_use/review_app/running.md @@ -35,7 +35,7 @@ where Command `mephisto review_app` supports the following options: -- `-h/--host` - host where TaskReview app will be served +- `-H/--host` - host where TaskReview app will be served - `-p/--port` - port where TaskReview app will be served - `-d/--debug` - run in debug mode (with extra logging) - `-f/--force-rebuild` - force rebuild React bundle (use if your Task client code has been updated) diff --git a/docs/web/docs/guides/how_to_use/video_annotator/configuration/config_files.md b/docs/web/docs/guides/how_to_use/video_annotator/configuration/config_files.md index f656fa3c1..22203c71a 100644 --- a/docs/web/docs/guides/how_to_use/video_annotator/configuration/config_files.md +++ b/docs/web/docs/guides/how_to_use/video_annotator/configuration/config_files.md @@ -46,6 +46,34 @@ Task data config file `task_data.json` specifies layout of all form versions tha "instruction": "
\n Please annotate everything you think is necessary.\n
\n\n\n", "video": "https://my-bucket.s3.amazonaws.com/my-folder/video.mp4", "show_instructions_as_modal": true, + "segment_fields": [ + { + "id": "id_title", + "label": "Segment name", + "name": "title", + "type": "input", + "validators": { + "required": true, + "minLength": 1, + "maxLength": 40 + } + }, + { + "id": "id_description", + "label": "Describe what you see in this segment", + "name": "description", + "type": "textarea", + "validators": { + "minLength": 2, + "maxLength": 500, + "checkForbiddenWords": true + }, + "triggers": { + "onFocus": ["onFocusDescription", "\"Describe what you see in this segment\""] + } + }, + { ... } + ], "submit_button": { "instruction": "If you are ready and think that you described everything, submit the results.", "text": "Submit", @@ -63,7 +91,7 @@ Task data config file `task_data.json` specifies layout of all form versions tha ] ``` -### Assignment config levels +### Unit config levels VideoAnnotator UI layout consists of the following layers of UI object hierarchy: @@ -83,7 +111,6 @@ While attributes values are limited to numbers and text, these fields (at any hi _Note that, due to limitations of JSON format, HTML content needs to be converted into a single long string of text._ You can style fields with HTML-classes in `classes` attribute. You can use any bootstrap classes or our built-in classes: -- `hidden` - if you need to hide element and show it later with custom triggerm, but you do not need it be a fully hidden field (`"type": "hidden"`) - `centered` - centered horizontally TBD: Other classes and styles insertions @@ -101,6 +128,7 @@ TBD: Other classes and styles insertions - `show_instructions_as_modal` - Enables showing `instruction` content as a modal (opened by clicking a sticky button in top-right corner); this make lengthy task instructions available from any place of a lengthy form without scrolling the page (Boolean, Optional, Default: false) - `title` - HTML header of the form (String) - `video` - URL to preuploaded video file (String) +- `segment_fields` - **List of fields** that will be added into each track segment - `submit_button` - Button to submit the whole form and thus finish a task (Object) - `id` - Unique HTML id of the button, in case we need to refer to it from custom handlers code (String, Optional) - `instruction` - Text shown above the "Submit" button (String, Optional) @@ -108,9 +136,90 @@ TBD: Other classes and styles insertions - `tooltip` - Browser tooltip shown on mouseover (String, Optional) - `triggers` - Functions that are being called on available React-events (`onClick`, see [JS trigger insertion](/docs/guides/how_to_use/video_annotator/configuration/insertions/#js-trigger-insertion)) +--- + +#### Config level: field + +Each item of `segment_fields` list is an object that corresponds to the track segment field displayed in the resulting Task UI page. + +Here's example of a single field config: + +```json +{ + "id": "id_title", + "label": "Segment name", + "name": "title", + "type": "input", + "validators": { + "required": true, + "minLength": 2, + "maxLength": 20, + "regexp": ["^[a-z\.\-']+$", "ig"] + // or can use this --> "regexp": "^[a-z\.\-']+$" + }, + "value": "" +} +``` + +--- + +### Attributes for "field" config level + +#### `value` attribute + +The `value` attribute specifies initial value of a field, and has the following format: + +- String for `input`, `textarea`, `email`, `number`, `password`, `radio`, and `select` with `"multiple": false` field types + - For `radio` and `select` fields, it must be one of the input options' values +- Object for `checkbox` + - The object should consist of all checkbox options with their Boolean value, e.g. `{"react": true, "python": true, "sql": false}` +- Array for `select` with `"multiple": true` + - All array items must be input options' values, e.g. `["python", "sql"]` + + +#### Attributes - all fields + +The most important attributes are: `label`, `name`, `type`, `validators` + +- `help` - HTML explanation of the field/fieldset displayed in small font below the field (String, Optional) +- `id` - Unique HTML id of the field, in case we need to refer to it from custom handlers code (String, Optional) +- `classes` = Custom classes that you can use to restyle element or refer to it from custom handlers code (String, Optional) +- `label` - Field name displayed above the field (String) +- `name` - Unique name under which this field's data will be sent to the server (String) +- `placeholder` - Text faintly displayed in the field before user provides a value (String, Optional) +- `tooltip` - Text shown in browser tooltip on mouseover (String, Optional) +- `type` - Type of the field (`input`, `email`, `select`, `textarea`, `checkbox`, `radio`, `file`, `hidden`) (String) +- `validators` - Validators preventing incorrect data from being submitted (Object[: String|Boolean|Number], Optional). Supported key-value pairs for the `validators` object: + - `required`: Ensure field is not left empty (Boolean) + - `minLength`: Ensure minimal number of typed characters or selected choices (Number) + - `maxLength`: Ensure maximum number of typed characters or selected choices (Number) + - `regexp`: Ensure provided value confirms to a Javascript regular expression. It can be: + - (String): a regexp string (e.g. `"^[a-zA-Z0-9._-]+$"`) in which case default matching flags are `igm` are used + - (2-item Array[String, String]): a regexp string followed by matching flags (e.g. `["^[a-zA-Z0-9._-]+$", "ig"]`) +- `value` - Initial value of the field (String, Optional) +- `triggers` - Functions that are being called on available React-events (`onClick`, `onChange`, `onBlur`, `onFocus`, see [JS trigger insertion](/docs/guides/how_to_use/video_annotator/configuration/insertions/#js-trigger-insertion)) + + +#### Attributes - select field + +- `multiple` - Support selection of multiple provided options, not just one (Boolean. Default: false) +- `options` - list of available options to select from. Each option is an object with these attributes: + - `label`: displayed text (String) + - `value`: value sent to the server (String|Number|Boolean) + + +#### Attributes - checkbox and radio fields + +- `options` - list of available options to select from. Each option is an object with these attributes: + - `label`: displayed text (String) + - `value`: value sent to the server (String|Number|Boolean) + - `checked`: initial state of selection (Boolean, default: false) + +_Note that, comparing FormComposer, VideoAnnotator segments do not have fields `file` and `hidden`._ + ## Config file: `unit_config.json` -Assignment config file `unit_config.json` specifies layout of an annotator in the same way as `task_data.json`, but with a few notable differences: +Unit config file `unit_config.json` specifies layout of an annotator in the same way as `task_data.json`, but with a few notable differences: - It contains a single JSON object (not a JSON array of objects) - Some of its form attributes definitions must contain dynamic tokens (whose values will be extrapolated, i.e. substituted with variable chunks of text) - see further below. diff --git a/examples/video_annotator_demo/data/dynamic/task_data.json b/examples/video_annotator_demo/data/dynamic/task_data.json index d6c6afc35..80a7a5d39 100644 --- a/examples/video_annotator_demo/data/dynamic/task_data.json +++ b/examples/video_annotator_demo/data/dynamic/task_data.json @@ -52,7 +52,7 @@ { "id": "id_person_name", "label": "Person name", - "help": "The name of talking person", + "help": "Select name of the person who is talking", "multiple": false, "name": "person_name", "type": "select", diff --git a/examples/video_annotator_demo/data/dynamic/unit_config.json b/examples/video_annotator_demo/data/dynamic/unit_config.json index f9bf460c9..5f68e528b 100644 --- a/examples/video_annotator_demo/data/dynamic/unit_config.json +++ b/examples/video_annotator_demo/data/dynamic/unit_config.json @@ -51,7 +51,7 @@ { "id": "id_person_name", "label": "Person name", - "help": "The name of talking person", + "help": "Select name of the person who is talking", "multiple": false, "name": "person_name", "type": "select", diff --git a/mephisto/client/cli.py b/mephisto/client/cli.py index 839a6face..ac6aa3520 100644 --- a/mephisto/client/cli.py +++ b/mephisto/client/cli.py @@ -12,7 +12,7 @@ from mephisto.client.cli_db_commands import db_cli from mephisto.client.cli_form_composer_commands import form_composer_cli from mephisto.client.cli_metrics_commands import metrics_cli -from mephisto.client.cli_review_app_commands import review_app +from mephisto.client.cli_review_app_commands import review_app_cli from mephisto.client.cli_scripts_commands import run_script from mephisto.client.cli_video_annotator_commands import video_annotator_cli from mephisto.client.cli_wut_commands import run_wut @@ -24,7 +24,12 @@ logger = ConsoleWriter() -@click.group(cls=RichGroup) +@click.group( + cls=RichGroup, + context_settings={ + "help_option_names": ["-h", "--help"], + }, +) def cli(): """[deep_sky_blue4]Bring your research ideas to life with powerful crowdsourcing tooling[/] @@ -179,7 +184,7 @@ def register_provider(args): run_script ) cli.command("wut", cls=RichCommand, context_settings={"ignore_unknown_options": True})(run_wut) -cli.command("review_app", cls=RichCommand)(review_app) +cli.add_command(review_app_cli) cli.add_command(form_composer_cli) cli.add_command(video_annotator_cli) cli.add_command(metrics_cli) diff --git a/mephisto/client/cli_db_commands.py b/mephisto/client/cli_db_commands.py index 388e4067b..45bdc9c04 100644 --- a/mephisto/client/cli_db_commands.py +++ b/mephisto/client/cli_db_commands.py @@ -37,7 +37,7 @@ def _print_used_options_for_running_command_message(ctx: click.Context, options: logger.debug(message) -@click.group(name="db", context_settings=dict(help_option_names=["-h", "--help"]), cls=RichGroup) +@click.group(name="db", cls=RichGroup) def db_cli(): """Operations with Mephisto DB and provider-specific datastores""" pass diff --git a/mephisto/client/cli_form_composer_commands.py b/mephisto/client/cli_form_composer_commands.py index 746252e8d..40ea511f2 100644 --- a/mephisto/client/cli_form_composer_commands.py +++ b/mephisto/client/cli_form_composer_commands.py @@ -77,12 +77,7 @@ def _get_form_composer_app_path() -> str: return app_path -@click.group( - name="form_composer", - context_settings=dict(help_option_names=["-h", "--help"]), - cls=RichGroup, - invoke_without_command=True, -) +@click.group(name="form_composer", cls=RichGroup, invoke_without_command=True) @click.pass_context @click.option( "-o", diff --git a/mephisto/client/cli_metrics_commands.py b/mephisto/client/cli_metrics_commands.py index 9426deab2..1c29300bf 100644 --- a/mephisto/client/cli_metrics_commands.py +++ b/mephisto/client/cli_metrics_commands.py @@ -22,11 +22,7 @@ logger = ConsoleWriter() -@click.group( - name="metrics", - context_settings=dict(help_option_names=["-h", "--help"]), - cls=RichGroup, -) +@click.group(name="metrics", cls=RichGroup) def metrics_cli(): """View task health and status with Mephisto Metrics""" pass diff --git a/mephisto/client/cli_review_app_commands.py b/mephisto/client/cli_review_app_commands.py index 943646239..1ec9502dc 100644 --- a/mephisto/client/cli_review_app_commands.py +++ b/mephisto/client/cli_review_app_commands.py @@ -11,21 +11,65 @@ import click from flask.cli import pass_script_info from flask.cli import ScriptInfo +from rich_click import RichGroup -from mephisto.utils.console_writer import ConsoleWriter from mephisto.tools.building_react_apps import review_app as _review_app +from mephisto.utils.console_writer import ConsoleWriter logger = ConsoleWriter() -@click.option("-h", "--host", type=str, default="127.0.0.1") -@click.option("-p", "--port", type=int, default=5000) -@click.option("-d", "--debug", type=bool, default=False, is_flag=True) -@click.option("-f", "--force-rebuild", type=bool, default=False, is_flag=True) -@click.option("-s", "--skip-build", type=bool, default=False, is_flag=True) +@click.group( + name="review_app", + cls=RichGroup, + invoke_without_command=True, +) +@click.pass_context +@click.option( + "-H", + "--host", + type=str, + default="127.0.0.1", + help="Host where TaskReview app will be served", +) +@click.option( + "-p", + "--port", + type=int, + default=5000, + help="Port where TaskReview app will be served", +) +@click.option( + "-d", + "--debug", + type=bool, + default=False, + is_flag=True, + help="Run in debug mode (with extra logging)", +) +@click.option( + "-f", + "--force-rebuild", + type=bool, + default=False, + is_flag=True, + help="Force rebuild React bundle (use if your Task client code has been updated)", +) +@click.option( + "-s", + "--skip-build", + type=bool, + default=False, + is_flag=True, + help=( + "Skip all installation and building steps for the UI, and directly launch the server " + "(use if no code has been changed)" + ), +) @pass_script_info -def review_app( +def review_app_cli( info: ScriptInfo, + ctx: click.Context, host: Optional[str], port: Optional[int], debug: bool = False, @@ -36,6 +80,12 @@ def review_app( Launch a local review server. Custom implementation of `flask run ` command (`flask.cli.run_command`) """ + + if ctx.invoked_subcommand is not None: + # It's needed to add the ability to run other commands, + # run default code only if there's no other command after `review_app` + return + from flask.cli import show_server_banner from flask.helpers import get_debug_flag from mephisto.review_app.server import create_app diff --git a/mephisto/client/cli_video_annotator_commands.py b/mephisto/client/cli_video_annotator_commands.py index 162b0e3e4..2eb755406 100644 --- a/mephisto/client/cli_video_annotator_commands.py +++ b/mephisto/client/cli_video_annotator_commands.py @@ -62,12 +62,7 @@ def _get_video_annotator_app_path() -> str: return app_path -@click.group( - name="video_annotator", - context_settings=dict(help_option_names=["-h", "--help"]), - cls=RichGroup, - invoke_without_command=True, -) +@click.group(name="video_annotator", cls=RichGroup, invoke_without_command=True) @click.pass_context @click.option( "-o", diff --git a/mephisto/generators/generators_utils/config_validation/utils.py b/mephisto/generators/generators_utils/config_validation/utils.py index cab3c7917..fb32d9547 100644 --- a/mephisto/generators/generators_utils/config_validation/utils.py +++ b/mephisto/generators/generators_utils/config_validation/utils.py @@ -26,13 +26,13 @@ from mephisto.generators.generators_utils.constants import CONTENTTYPE_BY_EXTENSION from mephisto.generators.generators_utils.constants import JSON_IDENTATION from mephisto.generators.generators_utils.constants import S3_URL_EXPIRATION_MINUTES -from mephisto.utils.logger_core import get_logger +from mephisto.utils.console_writer import ConsoleWriter from .config_validation_constants import CUSTOM_TRIGGERS_JS_FILE_NAME from .config_validation_constants import CUSTOM_TRIGGERS_JS_FILE_NAME_ENV_KEY from .config_validation_constants import CUSTOM_VALIDATORS_JS_FILE_NAME from .config_validation_constants import CUSTOM_VALIDATORS_JS_FILE_NAME_ENV_KEY -logger = get_logger(name=__name__) +logger = ConsoleWriter() s3_client = boto3.client("s3") diff --git a/mephisto/review_app/client/src/pages/TaskPage/TaskPage.css b/mephisto/review_app/client/src/pages/TaskPage/TaskPage.css index 1f6fad7cb..45b6f128b 100644 --- a/mephisto/review_app/client/src/pages/TaskPage/TaskPage.css +++ b/mephisto/review_app/client/src/pages/TaskPage/TaskPage.css @@ -39,6 +39,13 @@ color: #ccc; } +.task .review-board .right-side .info .task-name { + text-overflow: ellipsis; + max-width: 250px; + white-space: nowrap; + overflow: hidden; +} + .task .review-board .right-side .info .grey { color: #999; } diff --git a/mephisto/review_app/client/src/pages/TaskPage/TaskPage.tsx b/mephisto/review_app/client/src/pages/TaskPage/TaskPage.tsx index e0212b6b5..fe4cb6d9c 100644 --- a/mephisto/review_app/client/src/pages/TaskPage/TaskPage.tsx +++ b/mephisto/review_app/client/src/pages/TaskPage/TaskPage.tsx @@ -4,10 +4,12 @@ * LICENSE file in the root directory of this source tree. */ -import InitialParametersCollapsable from "components/InitialParametersCollapsable/InitialParametersCollapsable"; +import InitialParametersCollapsable + from "components/InitialParametersCollapsable/InitialParametersCollapsable"; import { InReviewFileModal } from "components/InReviewFileModal/InReviewFileModal"; import ResultsCollapsable from "components/ResultsCollapsable/ResultsCollapsable"; -import VideoAnnotatorWebVTTCollapsable from "components/VideoAnnotatorWebVTTCollapsable/VideoAnnotatorWebVTTCollapsable"; +import VideoAnnotatorWebVTTCollapsable + from "components/VideoAnnotatorWebVTTCollapsable/VideoAnnotatorWebVTTCollapsable"; import WorkerOpinionCollapsable from "components/WorkerOpinionCollapsable/WorkerOpinionCollapsable"; import { MESSAGES_IFRAME_DATA_KEY, @@ -470,6 +472,7 @@ function TaskPage(props: TaskPagePropsType) { getUnits(setUnits, setLoading, onError, { task_id: params.id, unit_ids: unitsOnReview[1].join(","), + completed: "true", }); } }, [unitsOnReview]); @@ -614,8 +617,8 @@ function TaskPage(props: TaskPagePropsType) {
{currentUnitDetails && ( <> -
- Task ID: {currentUnitDetails.task_id} +
+ Task name: {task.name}
Worker ID: {currentUnitDetails.worker_id} diff --git a/mephisto/review_app/client/src/pages/TaskUnitsPage/TaskUnitsPage.tsx b/mephisto/review_app/client/src/pages/TaskUnitsPage/TaskUnitsPage.tsx index 1e7c48875..ae8f400cb 100644 --- a/mephisto/review_app/client/src/pages/TaskUnitsPage/TaskUnitsPage.tsx +++ b/mephisto/review_app/client/src/pages/TaskUnitsPage/TaskUnitsPage.tsx @@ -108,14 +108,7 @@ function TaskUnitsPage(props: TaskUnitsPagePropsType) { return ( {unit.worker_id} - - - {unit.id} - - + {unit.id} {unit.is_reviewed ? : ""} @@ -133,7 +126,7 @@ function TaskUnitsPage(props: TaskUnitsPagePropsType) { to={urls.client.taskUnit(task.id, unit.id)} target={"_blank"} > - Details + View Unit diff --git a/mephisto/review_app/client/src/pages/TasksPage/TasksPage.tsx b/mephisto/review_app/client/src/pages/TasksPage/TasksPage.tsx index 7ed58a8d8..1388c98c4 100644 --- a/mephisto/review_app/client/src/pages/TasksPage/TasksPage.tsx +++ b/mephisto/review_app/client/src/pages/TasksPage/TasksPage.tsx @@ -117,7 +117,7 @@ function TasksPage(props: TasksPagePropsType) { Opinions - Display Unit + View Units Export results diff --git a/mephisto/review_app/client/src/pages/UnitPage/UnitPage.tsx b/mephisto/review_app/client/src/pages/UnitPage/UnitPage.tsx index 92dd1e516..75dfc9d5a 100644 --- a/mephisto/review_app/client/src/pages/UnitPage/UnitPage.tsx +++ b/mephisto/review_app/client/src/pages/UnitPage/UnitPage.tsx @@ -4,21 +4,21 @@ * LICENSE file in the root directory of this source tree. */ -import InitialParametersCollapsable from "components/InitialParametersCollapsable/InitialParametersCollapsable"; +import InitialParametersCollapsable + from "components/InitialParametersCollapsable/InitialParametersCollapsable"; import { InReviewFileModal } from "components/InReviewFileModal/InReviewFileModal"; import ResultsCollapsable from "components/ResultsCollapsable/ResultsCollapsable"; import TasksHeader from "components/TasksHeader/TasksHeader"; -import VideoAnnotatorWebVTTCollapsable from "components/VideoAnnotatorWebVTTCollapsable/VideoAnnotatorWebVTTCollapsable"; +import VideoAnnotatorWebVTTCollapsable + from "components/VideoAnnotatorWebVTTCollapsable/VideoAnnotatorWebVTTCollapsable"; import WorkerOpinionCollapsable from "components/WorkerOpinionCollapsable/WorkerOpinionCollapsable"; -import { - MESSAGES_IFRAME_DATA_KEY, - MESSAGES_IN_REVIEW_FILE_DATA_KEY, -} from "consts/review"; +import { MESSAGES_IFRAME_DATA_KEY, MESSAGES_IN_REVIEW_FILE_DATA_KEY } from "consts/review"; import { setPageTitle } from "helpers"; import * as React from "react"; import { useEffect } from "react"; import { Spinner } from "react-bootstrap"; import { useParams } from "react-router-dom"; +import { getTask } from 'requests/tasks'; import { getUnits, getUnitsDetails } from "requests/units"; import urls from "urls"; import "./UnitPage.css"; @@ -40,6 +40,7 @@ function UnitPage(props: UnitPagePropsType) { const [iframeLoaded, setIframeLoaded] = React.useState(false); const [iframeHeight, setIframeHeight] = React.useState(100); const [unit, setUnit] = React.useState(null); + const [task, setTask] = React.useState(null); const [loading, setLoading] = React.useState(false); const [unitDetails, setUnitDetails] = React.useState(null); @@ -142,6 +143,10 @@ function UnitPage(props: UnitPagePropsType) { setPageTitle("Mephisto - Task Review - Current Unit"); if (unit === null) { + if (task === null) { + getTask(params.taskId, setTask, setLoading, onError, null); + } + getUnits((units: UnitType[]) => setUnit(units[0]), setLoading, onError, { unit_ids: params.unitId, }); @@ -208,7 +213,7 @@ function UnitPage(props: UnitPagePropsType) {
Unit: {unit.id}
-
Task ID: {params.taskId}
+
Task name: {task.name}
Worker ID: {unit.worker_id}
diff --git a/mephisto/review_app/server/api/views/units_view.py b/mephisto/review_app/server/api/views/units_view.py index 56e184025..fcf1ad655 100644 --- a/mephisto/review_app/server/api/views/units_view.py +++ b/mephisto/review_app/server/api/views/units_view.py @@ -27,6 +27,7 @@ def get(self) -> dict: task_id_param = request.args.get("task_id") unit_ids_param: Optional[str] = request.args.get("unit_ids") + completed_param: Optional[str] = request.args.get("completed") app.logger.debug(f"Params: {task_id_param=}, {unit_ids_param=}") @@ -59,7 +60,7 @@ def get(self) -> dict: if task_id_param and unit.task_id != task_id_param: continue - if unit.db_status != AssignmentState.COMPLETED: + if completed_param and unit.db_status != AssignmentState.COMPLETED: continue try: diff --git a/mephisto/review_app/server/utils/video_annotator.py b/mephisto/review_app/server/utils/video_annotator.py index 252246644..221164e19 100644 --- a/mephisto/review_app/server/utils/video_annotator.py +++ b/mephisto/review_app/server/utils/video_annotator.py @@ -193,7 +193,7 @@ def convert_annotation_tracks_to_webvtt( return None webvtt = WebVTT( - header_comments=[f"Mephisto {task_name}"], + header_comments=[f"Mephisto Task \"{task_name}\""], footer_comments=["Copyright (c) Meta Platforms and its affiliates."], ) diff --git a/packages/mephisto-task-addons/src/VideoAnnotator/AnnotationTrack.css b/packages/mephisto-task-addons/src/VideoAnnotator/AnnotationTrack.css index c613023ab..6131b136f 100644 --- a/packages/mephisto-task-addons/src/VideoAnnotator/AnnotationTrack.css +++ b/packages/mephisto-task-addons/src/VideoAnnotator/AnnotationTrack.css @@ -10,23 +10,32 @@ min-height: 40px; display: flex; flex-direction: column; - background-color: #666666; - cursor: pointer; + background-color: var(--track-bg-color-inactive); } -.annotation-track:hover { - background-color: #777777; +.annotation-track:not(.non-clickable):not(.active):hover { + background-color: var(--track-bg-color-inactive-hover); + cursor: pointer; } .annotation-track.active { - background-color: #dddddd; + background-color: var(--track-bg-color-active); } .annotation-track .track-name-small { position: absolute; top: 2px; left: 4px; - font-size: 8px; + font-size: 10px; + color: #ffffff; +} + +.annotation-track .segments-count { + position: absolute; + top: 2px; + right: 4px; + font-size: 10px; + font-style: italic; color: #ffffff; } @@ -37,7 +46,7 @@ flex-direction: row; gap: 4px; align-items: center; - padding: 0 150px 0 10px; + padding: 0 190px 0 10px; } .annotation-track .track-info .track-name-label { @@ -58,7 +67,6 @@ } .annotation-track .track-info .buttons .btn { - width: 32px; } .annotation-track .track-info .track-buttons { @@ -75,7 +83,7 @@ .annotation-track .segments { position: relative; width: 100%; - height: 40px; + height: 50px; display: flex; flex-direction: row; gap: 4px; @@ -84,6 +92,14 @@ padding-right: var(--segments-padding-right); } +.annotation-track .segments .progress-bar { + width: 100%; + height: var(--segment-height-inactive); + background-color: #000000; + border-radius: 4px; + opacity: 0.2; +} + .annotation-track .segment-info { position: relative; display: flex; @@ -114,13 +130,29 @@ } .annotation-track .segment-info .field-label { - font-size: 14px; + font-size: 12px; } .annotation-track .segment-info .field-help { font-size: 10px; } +.annotation-track .segment-info .fc-radio-field .form-check-label, +.annotation-track .segment-info .fc-checkbox-field .form-check-label, +.annotation-track + .segment-info + .fc-select-field + .filter-option + .filter-option-inner-inner, +.annotation-track + .segment-info + .fc-select-field + .dropdown-menu + .dropdown-item + .text { + font-size: 0.875rem; /* Same as in inputs */ +} + .annotation-track .segment-info .segment-buttons { position: absolute; top: 10px; diff --git a/packages/mephisto-task-addons/src/VideoAnnotator/AnnotationTrack.jsx b/packages/mephisto-task-addons/src/VideoAnnotator/AnnotationTrack.jsx index 7ebc6796d..af033e668 100644 --- a/packages/mephisto-task-addons/src/VideoAnnotator/AnnotationTrack.jsx +++ b/packages/mephisto-task-addons/src/VideoAnnotator/AnnotationTrack.jsx @@ -8,26 +8,20 @@ import { cloneDeep } from "lodash"; import React from "react"; import { FieldType } from "../FormComposer/constants"; import { validateFormFields } from "../FormComposer/validation/helpers"; +import { pluralizeString } from "../helpers"; import { FormComposerFields, ListErrors } from "../index.jsx"; import "./AnnotationTrack.css"; +import { + COLORS, + DELAY_CLICK_ON_SECTION_MSEC, + INIT_SECTION, + POPOVER_INVALID_SEGMENT_CLASS, + POPOVER_INVALID_SEGMENT_PROPS, + START_NEXT_SECTION_PLUS_SEC, +} from "./constants"; import { secontsToTime } from "./helpers.jsx"; import TrackSegment from "./TrackSegment.jsx"; -// When we click on segment, we simulate clicking on track as well, and it must be first, -// but setting states is async -const DELAY_CLICK_ON_SECTION_MSEC = 200; - -const START_NEXT_SECTION_PLUS_SEC = 0; - -const COLORS = ["blue", "green", "orange", "purple", "red", "yellow", "brown"]; - -const INIT_SECTION = { - description: "", - end_sec: 0, - start_sec: 0, - title: "", -}; - function AnnotationTrack({ annotationTrack, customTriggers, @@ -74,12 +68,14 @@ function AnnotationTrack({ trackIndex - Math.floor(trackIndex / COLORS.length) * COLORS.length; const segmentsColor = COLORS[segmentsColorIndex]; - const showSegments = !!Object.keys(annotationTrack.segments).length; + const showSegments = true; const segmentFieldsByName = Object.fromEntries( segmentFields.map((x) => [x.name, x]) ); + const segmentsAmount = Object.keys(annotationTrack.segments).length; + // ----- Methods ----- function onClickTrack(e) { @@ -113,6 +109,7 @@ function AnnotationTrack({ }); setInEditState(false); + setSegmentValidation({}); } } @@ -129,6 +126,7 @@ function AnnotationTrack({ setInEditState(false); setSelectedSegment(null); setSegmentToChange(null); + setSegmentValidation({}); } } @@ -138,13 +136,25 @@ function AnnotationTrack({ const newSegment = cloneDeep(INIT_SECTION); newSegment.title = `Segment #${segmentsCount + 1} `; + // Get current video time and set it to new segment + let currentVideoTime = null; + if (player) { + currentVideoTime = player.currentTime() + START_NEXT_SECTION_PLUS_SEC; + } + newSegment.start_sec = currentVideoTime; + newSegment.end_sec = currentVideoTime; + if (segmentsCount !== 0) { const latestSegment = annotationTrack.segments[segmentsCount - 1]; - newSegment.start_sec = - latestSegment.end_sec + START_NEXT_SECTION_PLUS_SEC; - newSegment.end_sec = newSegment.start_sec; - // Prevent creating empty duplicates + // In case if player was not found somehow, create segment right after previous one + if (currentVideoTime === null) { + currentVideoTime = latestSegment.end_sec + START_NEXT_SECTION_PLUS_SEC; + newSegment.start_sec = currentVideoTime; + newSegment.end_sec = currentVideoTime; + } + + // Prevent creating empty duplicates with unset time fields if (latestSegment.start_sec === newSegment.start_sec) { alert( "You already have unfinished segment.\n\n" + @@ -164,6 +174,10 @@ function AnnotationTrack({ ...{ [trackIndex]: prevAnnotationTrack }, }; }); + + setSelectedSegment(newSegmentIndex); + setSegmentToChange(newSegment); + onSelectSegment && onSelectSegment(newSegment); } function validateTimeFieldsOnSave() { @@ -172,7 +186,7 @@ function AnnotationTrack({ // If start is greater than end if (segmentToChange.start_sec > segmentToChange.end_sec) { - errors.push(`Start of the section cannot be greater than end of it.`); + errors.push(`Start of the segment cannot be greater than end of it.`); validation.end_sec = false; } @@ -218,10 +232,12 @@ function AnnotationTrack({ function onClickSegment(e, segmentIndex) { player.pause(); - setTimeout( - () => setSelectedSegment(segmentIndex), - DELAY_CLICK_ON_SECTION_MSEC - ); + setTimeout(() => { + setSelectedSegment(segmentIndex); + const segment = annotationTrack.segments[segmentIndex]; + setSegmentToChange(segment); + onSelectSegment && onSelectSegment(segment); + }, DELAY_CLICK_ON_SECTION_MSEC); } function onClickSaveFormField(fieldName, value, e) { @@ -245,17 +261,6 @@ function AnnotationTrack({ } }, [selectedAnnotationTrack]); - React.useEffect(() => { - let _segmentToChange = null; - - if (selectedSegment !== null) { - _segmentToChange = annotationTrack.segments[selectedSegment]; - setSegmentToChange(_segmentToChange); - } - - onSelectSegment && onSelectSegment(_segmentToChange); - }, [selectedSegment]); - React.useEffect(() => { if (!segmentToChange) { return; @@ -297,12 +302,21 @@ function AnnotationTrack({ className={` annotation-track ${isSelectedAnnotationTrack ? "active" : ""} + ${!segmentIsValid ? "non-clickable" : ""} + ${POPOVER_INVALID_SEGMENT_CLASS} `} - onClick={(e) => onClickTrack()} + onClick={(e) => segmentIsValid && onClickTrack(e)} + {...POPOVER_INVALID_SEGMENT_PROPS} > {/* Short name on unactive track */} {!isSelectedAnnotationTrack && ( -
{annotationTrack.title}
+ <> +
{annotationTrack.title}
+ +
+ {segmentsAmount} {pluralizeString("segment", segmentsAmount)} +
+ )} {isSelectedAnnotationTrack && ( @@ -348,7 +362,7 @@ function AnnotationTrack({ type={"button"} onClick={(e) => onClickEditTrackInfo(e)} > - + Track )} @@ -360,14 +374,15 @@ function AnnotationTrack({ type={"button"} onClick={(e) => onClickRemoveTrack(e)} > - + Track @@ -385,6 +400,14 @@ function AnnotationTrack({ "--segments-padding-right": `${paddingRight}px`, }} > +
+ {Object.entries(annotationTrack.segments).map( ([segmentIndex, segment]) => { return ( @@ -392,11 +415,12 @@ function AnnotationTrack({ duration={duration} isSelectedAnnotationTrack={isSelectedAnnotationTrack} key={`track-segment-${segmentIndex}`} - onClickSegment={onClickSegment} + onClickSegment={(e, index) => segmentIsValid && onClickSegment(e, index)} paddingLeft={paddingLeft} playerSizes={playerSizes} segment={segment} segmentIndex={segmentIndex} + segmentIsValid={segmentIsValid} segmentsColor={segmentsColor} selectedSegment={selectedSegment} /> @@ -434,7 +458,7 @@ function AnnotationTrack({ e ) } - title={"Save current player time as a start of this section"} + title={"Save current player time as a start of this segment"} > @@ -460,7 +484,7 @@ function AnnotationTrack({ e ) } - title={"Save current player time as an end of this section"} + title={"Save current player time as an end of this segment"} > diff --git a/packages/mephisto-task-addons/src/VideoAnnotator/AnnotationTracks.jsx b/packages/mephisto-task-addons/src/VideoAnnotator/AnnotationTracks.jsx index a5592dcb5..97bbf8d92 100644 --- a/packages/mephisto-task-addons/src/VideoAnnotator/AnnotationTracks.jsx +++ b/packages/mephisto-task-addons/src/VideoAnnotator/AnnotationTracks.jsx @@ -7,16 +7,16 @@ import { cloneDeep } from "lodash"; import React from "react"; import AnnotationTrack from "./AnnotationTrack.jsx"; +import "./AnnotationTracks.css"; +import { + INIT_ANNOTATION_TRACK, + POPOVER_INVALID_SEGMENT_CLASS, + POPOVER_INVALID_SEGMENT_PROPS, +} from "./constants"; import { convertAnnotationTasksDataObjectsRoLists, convertInitialDataListsToObjects, } from "./helpers.jsx"; -import "./AnnotationTracks.css"; - -const INIT_ANNOTATION_TRACK = { - title: "", - segments: {}, -}; function AnnotationTracks({ className, @@ -83,10 +83,11 @@ function AnnotationTracks({ {!inReviewState && (
diff --git a/packages/mephisto-task-addons/src/VideoAnnotator/TrackSegment.css b/packages/mephisto-task-addons/src/VideoAnnotator/TrackSegment.css index 288d51680..0d0894295 100644 --- a/packages/mephisto-task-addons/src/VideoAnnotator/TrackSegment.css +++ b/packages/mephisto-task-addons/src/VideoAnnotator/TrackSegment.css @@ -7,21 +7,21 @@ .segment { position: absolute; width: 40px; - height: 8px; + height: var(--segment-height-inactive); background-color: #ffffff; border-radius: 4px; border: 1px solid #dddddd; box-sizing: border-box; - cursor: pointer; opacity: 0.8; } -.segment:hover { - height: 14px; +.segment:not(.non-clickable):hover { + height: var(--segment-height-active); opacity: 1; + cursor: pointer; } .segment.active { - height: 14px; + height: var(--segment-height-active); opacity: 1; } diff --git a/packages/mephisto-task-addons/src/VideoAnnotator/TrackSegment.jsx b/packages/mephisto-task-addons/src/VideoAnnotator/TrackSegment.jsx index 72d1e7d90..c293f74ce 100644 --- a/packages/mephisto-task-addons/src/VideoAnnotator/TrackSegment.jsx +++ b/packages/mephisto-task-addons/src/VideoAnnotator/TrackSegment.jsx @@ -6,11 +6,10 @@ import { Popover } from "bootstrap"; import React from "react"; +import { MIN_SEGMENT_WIDTH_PX } from "./constants"; import { secontsToTime } from "./helpers.jsx"; import "./TrackSegment.css"; -const MIN_SEGMENT_WIDTH_PX = 6; - function TrackSegment({ duration, isSelectedAnnotationTrack, @@ -19,11 +18,13 @@ function TrackSegment({ playerSizes, segment, segmentIndex, + segmentIsValid, segmentsColor, selectedSegment, }) { const isSelectedSegment = - isSelectedAnnotationTrack && selectedSegment === segmentIndex; + isSelectedAnnotationTrack && + String(selectedSegment) === String(segmentIndex); let oneSecWidthPx = 0; if (playerSizes.progressBar?.width) { @@ -31,7 +32,7 @@ function TrackSegment({ } const leftPositionPx = paddingLeft + segment.start_sec * oneSecWidthPx; let segmentWidthPx = (segment.end_sec - segment.start_sec) * oneSecWidthPx; - // In case if section is too narrow, we need to make it a bit vissible + // In case if segment is too narrow, we need to make it a bit vissible if (segmentWidthPx < MIN_SEGMENT_WIDTH_PX) { segmentWidthPx = MIN_SEGMENT_WIDTH_PX; } @@ -53,6 +54,7 @@ function TrackSegment({ className={` segment ${isSelectedSegment ? "active" : ""} + ${!segmentIsValid ? "non-clickable" : ""} `} id={segmentId} style={{ diff --git a/packages/mephisto-task-addons/src/VideoAnnotator/VideoAnnotator.css b/packages/mephisto-task-addons/src/VideoAnnotator/VideoAnnotator.css index d81006feb..263cf2905 100644 --- a/packages/mephisto-task-addons/src/VideoAnnotator/VideoAnnotator.css +++ b/packages/mephisto-task-addons/src/VideoAnnotator/VideoAnnotator.css @@ -7,9 +7,16 @@ .video-annotation { --annotator-width: 700px; + --track-bg-color-active: #ecdadf; + --track-bg-color-inactive: #666666; + --track-bg-color-inactive-hover: #777777; + --segments-padding-left: initial; --segments-padding-right: initial; + --segment-height-active: 22px; + --segment-height-inactive: 12px; + --blue-color: #439acb; --brown-color: #9f4f33; --green-color: #64b943; @@ -44,7 +51,7 @@ margin-bottom: 20px; } -.video-annotation .video-player { +.video-annotation .video-player-container { margin-bottom: 20px; } diff --git a/packages/mephisto-task-addons/src/VideoAnnotator/VideoAnnotator.jsx b/packages/mephisto-task-addons/src/VideoAnnotator/VideoAnnotator.jsx index 0918bc6a6..0285a83a6 100644 --- a/packages/mephisto-task-addons/src/VideoAnnotator/VideoAnnotator.jsx +++ b/packages/mephisto-task-addons/src/VideoAnnotator/VideoAnnotator.jsx @@ -4,28 +4,22 @@ * LICENSE file in the root directory of this source tree. */ +import { Popover } from "bootstrap"; import React from "react"; import { getFormatStringWithTokensFunction } from "../FormComposer/utils"; import TaskInstructionButton from "../TaskInstructionModal/TaskInstructionButton"; import TaskInstructionModal from "../TaskInstructionModal/TaskInstructionModal"; import AnnotationTracks from "./AnnotationTracks.jsx"; +import { + DEFAULT_SEGMENT_FIELDS, + DELAY_PROGRESSBAR_RESIZING_MSEC, + POPOVER_INVALID_SEGMENT_CLASS, + POPOVER_INVALID_SEGMENT_PROPS, + STORAGE_PRESAVED_ANNOTATION_TRACKS_KEY, +} from "./constants"; import "./VideoAnnotator.css"; import VideoPlayer from "./VideoPlayer.jsx"; -const DELAY_PROGRESSBAR_RESIZING_MSEC = 1000; - -const STORAGE_PRESAVED_ANNOTATION_TRACKS_KEY = "annotation_tracks"; - -// In case if user does not specify any field -const DEFAULT_SEGMENT_FIELDS = [ - { - id: "id_title", - label: "Segment name", - name: "title", - type: "input", - }, -]; - function VideoAnnotator({ data, onSubmit, @@ -115,6 +109,7 @@ function VideoAnnotator({ playbackRates: [0.5, 1, 1.5, 2], preload: "auto", controlBar: { + chaptersButton: true, fullscreenToggle: false, pictureInPictureToggle: false, }, @@ -154,15 +149,20 @@ function VideoAnnotator({ // Stop playing the video at the end of the selected segment player.on("timeupdate", () => { // HACK to pass values into event listeners as them cannot read updated React states - const sectionElement = document.querySelectorAll(`.segment.active`)[0]; - if (!sectionElement) { + const segmentElement = document.querySelectorAll(`.segment.active`)[0]; + if (!segmentElement) { return; } - const endSec = Number.parseFloat(sectionElement.dataset.endsec) || null; + const endSec = Number.parseFloat(segmentElement.dataset.endsec) || null; if (endSec === null) { return; } + // HACK to prevent setting player on pause if current time is out of current segment + const videoPlayerElement = document.querySelectorAll(`.video-player`)[0]; + const lastTimePressedPlay = + Number.parseFloat(videoPlayerElement.dataset.lasttimepressedplay) || 0; + // Check for end only if video is playing const isVideoPlaying = !!( player.currentTime() > 0 && @@ -170,8 +170,11 @@ function VideoAnnotator({ !player.ended() && player.readyState() > 2 ); + if (isVideoPlaying) { - if (player.currentTime() >= endSec) { + // We pause video only in case if video was started before ending current segment. + // Otherwise, we should continue playing. + if (lastTimePressedPlay < endSec && player.currentTime() >= endSec) { player.pause(); // HACK: setting exact end value on progress bar, // because this event is being fired every 15-250 milliseconds, @@ -192,10 +195,11 @@ function VideoAnnotator({ } } + // ----- Methods ----- + function onSelectSegment(segment) { const player = playerRef.current; - // Set start if (player) { const startTime = segment?.start_sec || 0; player.currentTime(startTime); @@ -222,6 +226,35 @@ function VideoAnnotator({ localStorage.removeItem(STORAGE_PRESAVED_ANNOTATION_TRACKS_KEY); } + // ----- Effects ----- + + React.useEffect(() => { + // NOTE that we search for all buttons we disable if segment form is invalid: + // - VideoAnnotator ("Submit") + // - AnnotationTracks ("+ Track") + // - AnnotationTrack ("+ Segment") + + let popovers = []; + + // Create popover objects every time when segment marked as invalid + if (!segmentIsValid) { + popovers = [ + ...document.querySelectorAll( + `.${POPOVER_INVALID_SEGMENT_CLASS}:not(.active)[data-toggle="popover"]` + ), + ].map((el) => new Popover(el)); + } + // Remove popover objects every time when segment marked as valid + else { + popovers.map((p) => p.dispose()); + } + + // Do not forget to remove all popovers unmounting component to save memory + return () => { + popovers.map((p) => p.dispose()); + }; + }, [segmentIsValid]); + return (
{/* Task info */} @@ -235,7 +268,7 @@ function VideoAnnotator({ {annotatorInstruction && (
- For instructions, click "Task Instruction" button in the top-right + For instructions, click "Task Instructions" button in the top-right corner.
)} @@ -262,9 +295,10 @@ function VideoAnnotator({ )} {/* Video Player */} -
+
@@ -322,11 +356,12 @@ function VideoAnnotator({ {/* Submit button */}
diff --git a/packages/mephisto-task-addons/src/VideoAnnotator/VideoPlayer.jsx b/packages/mephisto-task-addons/src/VideoAnnotator/VideoPlayer.jsx index e5b10d7ea..9b3b0fb51 100644 --- a/packages/mephisto-task-addons/src/VideoAnnotator/VideoPlayer.jsx +++ b/packages/mephisto-task-addons/src/VideoAnnotator/VideoPlayer.jsx @@ -7,13 +7,7 @@ import React, { useEffect, useRef, useState } from "react"; import videojs from "video.js"; import "video.js/dist/video-js.css"; - -const CHAPTERS_SETTINGS = { - kind: "chapters", - label: "Chapters", - language: "en", - mode: "showing", -}; +import { CHAPTERS_SETTINGS } from "./constants"; function VideoPlayer({ chapters, className, onReady, options }) { const videoRef = useRef(null); @@ -21,6 +15,7 @@ function VideoPlayer({ chapters, className, onReady, options }) { const [chaptersTrack, setChaptersTrack] = useState(null); const [playerIsReady, setPlayerIsReady] = useState(false); + const [lastTimePressedPlay, setLastTimePressedPlay] = useState(false); // ----- Methods ----- @@ -39,7 +34,7 @@ function VideoPlayer({ chapters, className, onReady, options }) { track.mode = "disabled"; // Remove previously created cues to clear memory (I hope) - if (track.cues) { + if (Array.isArray(track.cues)) { [...track.cues].forEach((cue) => { track.removeCue(cue); }); @@ -89,6 +84,12 @@ function VideoPlayer({ chapters, className, onReady, options }) { useEffect(() => { const player = playerRef.current; + // Add time of previous pressing on Play button in HTML + // for HACK where we pause video in the end of current segment + player.on("play", () => { + setLastTimePressedPlay(player.currentTime()); + }); + return () => { if (player && !player.isDisposed()) { player.dispose(); @@ -104,7 +105,11 @@ function VideoPlayer({ chapters, className, onReady, options }) { }, [chapters]); return ( -
+
); diff --git a/packages/mephisto-task-addons/src/VideoAnnotator/constants.js b/packages/mephisto-task-addons/src/VideoAnnotator/constants.js new file mode 100644 index 000000000..181205909 --- /dev/null +++ b/packages/mephisto-task-addons/src/VideoAnnotator/constants.js @@ -0,0 +1,66 @@ +/* + * Copyright (c) Meta Platforms and its affiliates. + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +export const DELAY_PROGRESSBAR_RESIZING_MSEC = 1000; + +export const STORAGE_PRESAVED_ANNOTATION_TRACKS_KEY = "annotation_tracks"; + +// In case if user does not specify any field +export const DEFAULT_SEGMENT_FIELDS = [ + { + id: "id_title", + label: "Segment name", + name: "title", + type: "input", + }, +]; + +export const INIT_ANNOTATION_TRACK = { + title: "", + segments: {}, +}; + +// When we click on segment, we simulate clicking on track as well, and it must be first, +// but setting states is async +export const DELAY_CLICK_ON_SECTION_MSEC = 200; + +export const START_NEXT_SECTION_PLUS_SEC = 0; + +export const COLORS = [ + "blue", + "green", + "orange", + "purple", + "red", + "yellow", + "brown", +]; + +export const INIT_SECTION = { + description: "", + end_sec: 0, + start_sec: 0, + title: "", +}; + +export const MIN_SEGMENT_WIDTH_PX = 6; + +export const POPOVER_INVALID_SEGMENT_CLASS = "with-segment-validation"; + +export const POPOVER_INVALID_SEGMENT_PROPS = { + "data-html": true, + "data-placement": "top", + "data-content": "Please fix provided data before continuing", + "data-toggle": "popover", + "data-trigger": "hover", +}; + +export const CHAPTERS_SETTINGS = { + kind: "chapters", + label: "Segments", + language: "en", + mode: "showing", +}; diff --git a/packages/mephisto-task-addons/src/helpers/format.js b/packages/mephisto-task-addons/src/helpers/format.js new file mode 100644 index 000000000..90b65cee6 --- /dev/null +++ b/packages/mephisto-task-addons/src/helpers/format.js @@ -0,0 +1,28 @@ +/* + * Copyright (c) Meta Platforms and its affiliates. + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +export function pluralizeString(str, num, str_plural) { + if (num !== 1) { + if (!!str_plural) { + return str_plural; + } + else{ + let pluralizedEnding = ''; + if (str.endsWith('s') || str.endsWith('ch')) { + pluralizedEnding = 'es'; + } + else if (str.endsWith('z')) { + pluralizedEnding = 'zes'; + } + else { + pluralizedEnding = 's'; + } + return `${str}${pluralizedEnding}`; + } + } + + return str; +} diff --git a/packages/mephisto-task-addons/src/helpers/index.js b/packages/mephisto-task-addons/src/helpers/index.js new file mode 100644 index 000000000..08d121006 --- /dev/null +++ b/packages/mephisto-task-addons/src/helpers/index.js @@ -0,0 +1,7 @@ +/* + * Copyright (c) Meta Platforms and its affiliates. + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +export * from "./format"; diff --git a/packages/mephisto-task-addons/src/index.jsx b/packages/mephisto-task-addons/src/index.jsx index b1bf69b52..4d1676cae 100644 --- a/packages/mephisto-task-addons/src/index.jsx +++ b/packages/mephisto-task-addons/src/index.jsx @@ -11,6 +11,7 @@ import { TOKEN_START_SYMBOLS, } from "./FormComposer/constants"; import * as FormComposerFields from "./FormComposer/fields"; +import * as helpers from "./helpers"; import { Errors as ListErrors } from "./FormComposer/fields/Errors"; import { FormComposer } from "./FormComposer/FormComposer"; import { @@ -39,6 +40,7 @@ export { VideoAnnotator, VideoPlayer, WorkerOpinion, + helpers, prepareFormData, prepareRemoteProcedures, prepareVideoAnnotatorData, diff --git a/test/generators/form_composer/config_validation/test_task_data_config.py b/test/generators/form_composer/config_validation/test_task_data_config.py index 5df7cceef..97157a07c 100644 --- a/test/generators/form_composer/config_validation/test_task_data_config.py +++ b/test/generators/form_composer/config_validation/test_task_data_config.py @@ -23,6 +23,9 @@ from mephisto.generators.form_composer.config_validation.task_data_config import ( collect_unit_config_items_to_extrapolate, ) +from mephisto.generators.form_composer.config_validation.task_data_config import ( + verify_form_composer_configs, +) from mephisto.generators.generators_utils.config_validation.task_data_config import ( _collect_tokens_from_unit_config, ) @@ -873,7 +876,7 @@ def test_verify_form_composer_configs_errors(self, *args, **kwargs): captured_print_output = io.StringIO() sys.stdout = captured_print_output - verify_generator_configs( + verify_form_composer_configs( task_data_config_path, unit_config_path, token_sets_values_config_path, @@ -934,7 +937,7 @@ def test_verify_form_composer_configs_errors_task_data_config_only(self, *args, captured_print_output = io.StringIO() sys.stdout = captured_print_output - verify_generator_configs( + verify_form_composer_configs( task_data_config_path, unit_config_path, token_sets_values_config_path, diff --git a/test/review_app/server/api/test_task_export_results_view.py b/test/review_app/server/api/test_task_export_results_view.py index 3dc3f093a..6defa970b 100644 --- a/test/review_app/server/api/test_task_export_results_view.py +++ b/test/review_app/server/api/test_task_export_results_view.py @@ -62,7 +62,22 @@ def test_task_export_result_not_found_error(self, *args, **kwargs): self.assertEqual(response.status_code, http_status.HTTP_404_NOT_FOUND) - def test_task_export_result_not_reviews_error(self, *args, **kwargs): + @patch( + ( + "mephisto.review_app.server.api.views.task_export_results_view." + "ENABLE_INCOMPLETE_TASK_RESULTS_EXPORT" + ), + False, + ) + @patch("mephisto.review_app.server.api.views.task_export_results_view.check_if_task_reviewed") + def test_task_export_result_not_reviews_error( + self, + mock_check_if_task_reviewed, + *args, + **kwargs, + ): + mock_check_if_task_reviewed.return_value = False + unit_id = get_test_unit(self.db) unit: Unit = Unit.get(self.db, unit_id) unit.set_db_status(AssignmentState.COMPLETED) diff --git a/test/review_app/server/api/test_tasks_view.py b/test/review_app/server/api/test_tasks_view.py index c6f22bcc5..679b91809 100644 --- a/test/review_app/server/api/test_tasks_view.py +++ b/test/review_app/server/api/test_tasks_view.py @@ -38,7 +38,9 @@ def test_one_task_success(self, *args, **kwargs): self.assertEqual(first_response_task["name"], task_name) self.assertTrue("created_at" in first_response_task) self.assertTrue("is_reviewed" in first_response_task) - self.assertTrue("unit_count" in first_response_task) + self.assertTrue("unit_all_count" in first_response_task) + self.assertTrue("unit_completed_count" in first_response_task) + self.assertTrue("unit_finished_count" in first_response_task) self.assertTrue("has_stats" in first_response_task)