Repository: oppia/oppia

Issue:
Make shared constants accesible from AppConstants without Typescript error.
**Problem**
`AppConstants` also contain constants that are defined in `assets/constants.js` but since these are added dynamically by `Object.assign(AppConstants, constants);` attempt to access them produces a typescript error (`error TS2339: Property 'XXX' does not exist on type 'typeof AppConstants'.`)

cc @kevinlee12 @YashJipkate 
Make shared constants accesible from AppConstants without Typescript error.
**Problem**
`AppConstants` also contain constants that are defined in `assets/constants.js` but since these are added dynamically by `Object.assign(AppConstants, constants);` attempt to access them produces a typescript error (`error TS2339: Property 'XXX' does not exist on type 'typeof AppConstants'.`)

cc @kevinlee12 @YashJipkate 


Discussion:
@vojtechjelinek Hi, I investigated this issue and I think it can be done if we have another class say `ConstantsClass` that has all the constants defined in the `constants.ts` file and then we can simply change
```typescript
export class AppConstants {
  ...
}
``` 
to
```typescript
export class AppConstants extends ConstantsClass {
  ...
}
```
and then we would be able to access the values in ConstansClass from the AppConstants.

Now, we can't straightaway change the format of `constants.ts` file from json to a class as a lot of backend code (For eg `constants.py`) is dependent on it being defined in that way i.e. in object(json) format.

The only solution I could think was to do this instead of `Object.assign`
```typescript
var constants = require('constants.ts');
export class AppConstants {
  ...,
  public static DEV_MODE = constants.DEV_MODE;
  // Similarly with other constants in the constants.js file
}
```
Thoughts?
/cc @ankita240796 @seanlip 
@nishantwrp I don't have an opinion on this -- deferring to @vojtechjelinek @bansalnitish @ankita240796 to make a decision. Thanks!
@nishantwrp I don't really like the need to repeat every variable that we define in `constants.ts` in this new file.

I'm actually thinking that changing the format of `constants.ts` and then the Python constants parser might be a better approach here. However, I'm not sure how hard it would be to implement the Python parser for the new type of file.
Hi @vojtechjelinek, I have made a new parser that works correctly. Here it is
```python
def parse_json_from_constants_class(js_file):
    """Extracts JSON object from Constants Class."""
    text = js_file.read()
    text_without_comments = remove_comments(text)
    first_bracket_index = text_without_comments.find('Constants {')
    last_bracket_index = text_without_comments.rfind('}')
    constants_class = (
        text_without_comments[first_bracket_index + 10:last_bracket_index + 1]
    )
    intermediate_text = re.sub(r'\ =', r'":', constants_class)
    intermediate_text = re.sub(r';', r',', intermediate_text)
    json_text = re.sub(r'public\ static\ ', r'"', intermediate_text)
    # Remove the extra comma
    json_text = re.sub(r',\n}', r'\n}', json_text)
    return json.loads(json_text)
```
I was thinking to move all the constants in the `constants.ts` file to `app.constants.ts` file. Is there a reason why there are two different files for constants?

/cc @bansalnitish @ankita240796 
Yeah, I agree with what Vojta said.
@nishantwrp LGTM! The thing is that constants in `constants.ts` are used both in backend and frontend, the constants in `app.constants.ts` are used only in the frontend.

You can create a PR for these changes if you want. 
Ok, So, we can have a single constants file, right?
Yeah we can have.
@vojtechjelinek Hi, I investigated this issue and I think it can be done if we have another class say `ConstantsClass` that has all the constants defined in the `constants.ts` file and then we can simply change
```typescript
export class AppConstants {
  ...
}
``` 
to
```typescript
export class AppConstants extends ConstantsClass {
  ...
}
```
and then we would be able to access the values in ConstansClass from the AppConstants.

Now, we can't straightaway change the format of `constants.ts` file from json to a class as a lot of backend code (For eg `constants.py`) is dependent on it being defined in that way i.e. in object(json) format.

The only solution I could think was to do this instead of `Object.assign`
```typescript
var constants = require('constants.ts');
export class AppConstants {
  ...,
  public static DEV_MODE = constants.DEV_MODE;
  // Similarly with other constants in the constants.js file
}
```
Thoughts?
/cc @ankita240796 @seanlip 
@nishantwrp I don't have an opinion on this -- deferring to @vojtechjelinek @bansalnitish @ankita240796 to make a decision. Thanks!
@nishantwrp I don't really like the need to repeat every variable that we define in `constants.ts` in this new file.

I'm actually thinking that changing the format of `constants.ts` and then the Python constants parser might be a better approach here. However, I'm not sure how hard it would be to implement the Python parser for the new type of file.
Hi @vojtechjelinek, I have made a new parser that works correctly. Here it is
```python
def parse_json_from_constants_class(js_file):
    """Extracts JSON object from Constants Class."""
    text = js_file.read()
    text_without_comments = remove_comments(text)
    first_bracket_index = text_without_comments.find('Constants {')
    last_bracket_index = text_without_comments.rfind('}')
    constants_class = (
        text_without_comments[first_bracket_index + 10:last_bracket_index + 1]
    )
    intermediate_text = re.sub(r'\ =', r'":', constants_class)
    intermediate_text = re.sub(r';', r',', intermediate_text)
    json_text = re.sub(r'public\ static\ ', r'"', intermediate_text)
    # Remove the extra comma
    json_text = re.sub(r',\n}', r'\n}', json_text)
    return json.loads(json_text)
```
I was thinking to move all the constants in the `constants.ts` file to `app.constants.ts` file. Is there a reason why there are two different files for constants?

/cc @bansalnitish @ankita240796 
Yeah, I agree with what Vojta said.
@nishantwrp LGTM! The thing is that constants in `constants.ts` are used both in backend and frontend, the constants in `app.constants.ts` are used only in the frontend.

You can create a PR for these changes if you want. 
Ok, So, we can have a single constants file, right?
Yeah we can have.

Resolving patch:
diff --git a/scripts/linters/js_ts_linter.py b/scripts/linters/js_ts_linter.py
--- a/scripts/linters/js_ts_linter.py
+++ b/scripts/linters/js_ts_linter.py
@@ -854,21 +854,27 @@ def _check_constants_declaration(self):
             # *.constants.ajs.ts file are in sync.
             if filepath.endswith('.constants.ts') and (
                     is_corresponding_angularjs_filepath):
-                # Ignore if file contains only type definitions for
-                # constants.
                 for node in parsed_nodes:
-                    if 'declarations' in node.keys():
-                        try:
-                            angular_constants_nodes = (
-                                node.declarations[0].init.callee.body.body)
-                        except Exception:
-                            continue
-                for angular_constant_node in angular_constants_nodes:
-                    if not angular_constant_node.expression:
+                    try:
+                        # Here we are treating 'app.constants.ts' differently
+                        # because it has a different structure than rest of the
+                        # '*constants.ts' files because it inherits constants
+                        # from 'assets/constants.ts'.
+                        angular_constants_nodes = (
+                            node.expression.right.arguments[1].properties
+                            if filepath.endswith('app.constants.ts') else
+                            node.expression.right.properties)
+                    # We need a try-except block here beacuse we need a node
+                    # that has properties exactly as we have defined above.
+                    # And some nodes may not have those properties and may
+                    # raise the following errors. So, we need to ignore
+                    # those nodes.
+                    except (AttributeError, IndexError, TypeError):
                         continue
-                    angular_constant_name = (
-                        angular_constant_node.expression.left.property.name)
-                    angular_constants_list.append(angular_constant_name)
+
+                for angular_constant_node in angular_constants_nodes:
+                    angular_constants_list.append(
+                        angular_constant_node.key.name)
 
                 angular_constants_set = set(angular_constants_list)
                 if len(angular_constants_set) != len(
@@ -878,6 +884,19 @@ def _check_constants_declaration(self):
                         '%s --> Duplicate constant declaration found.'
                         % filepath)
                     error_messages.append(error_message)
+
+            # Check that the *constants.ts files have a 'as const' at the end.
+            if filepath.endswith('.constants.ts'):
+                file_content = self.file_cache.read(filepath)
+                if not file_content.endswith('} as const;\n'):
+                    failed = True
+                    error_message = (
+                        '%s --> This constants file doesn\'t have \'as const\' '
+                        'at the end. A constants file should have the '
+                        'following structure.\nexport const SomeConstants = '
+                        '{ ... } as const;' % filepath)
+                    error_messages.append(error_message)
+
         return concurrent_task_utils.TaskResult(
             name, failed, error_messages, error_messages)
 
diff --git a/scripts/release_scripts/update_changelog_and_credits.py b/scripts/release_scripts/update_changelog_and_credits.py
--- a/scripts/release_scripts/update_changelog_and_credits.py
+++ b/scripts/release_scripts/update_changelog_and_credits.py
@@ -52,8 +52,8 @@
 # These constants should match the format defined in
 # about-page.constants.ts. If the patterns do not match,
 # update_changelog_and_credits_test will fail.
-CREDITS_START_LINE = '  public static CREDITS_CONSTANTS = [\n'
-CREDITS_END_LINE = '  ];\n'
+CREDITS_START_LINE = '  CREDITS_CONSTANTS: [\n'
+CREDITS_END_LINE = '  ]\n'
 CREDITS_INDENT = '    '
 
 # This ordering should not be changed. The automatic updates to


Test patch:
diff --git a/core/templates/domain/review_test/review-test-domain.constants.ts b/core/templates/domain/review_test/review-test-domain.constants.ts
--- a/core/templates/domain/review_test/review-test-domain.constants.ts
+++ b/core/templates/domain/review_test/review-test-domain.constants.ts
@@ -16,8 +16,8 @@
  * @fileoverview Constants for review test domain.
  */
 
-export class ReviewTestDomainConstants {
-  public static REVIEW_TEST_DATA_URL = (
+export const ReviewTestDomainConstants = {
+  REVIEW_TEST_DATA_URL: (
     '/review_test_handler/data/<classroom_url_fragment>/' +
-    '<topic_url_fragment>/<story_url_fragment>');
-}
+    '<topic_url_fragment>/<story_url_fragment>')
+} as const;
diff --git a/core/templates/tests/console_errors.module.ts b/core/templates/tests/console_errors.module.ts
--- a/core/templates/tests/console_errors.module.ts
+++ b/core/templates/tests/console_errors.module.ts
@@ -35,12 +35,6 @@ import { SharedComponentsModule } from 'components/shared-component.module';
 import { OppiaAngularRootComponent } from
   'components/oppia-angular-root.component';
 
-import { AppConstants } from 'app.constants';
-import { InteractionsExtensionsConstants } from
-  'interactions/interactions-extension.constants';
-import { ObjectsDomainConstants } from
-  'domain/objects/objects-domain.constants';
-
 @NgModule({
   imports: [
     BrowserModule,
@@ -54,9 +48,6 @@ import { ObjectsDomainConstants } from
     OppiaAngularRootComponent
   ],
   providers: [
-    AppConstants,
-    InteractionsExtensionsConstants,
-    ObjectsDomainConstants,
     {
       provide: HTTP_INTERCEPTORS,
       useClass: RequestInterceptor,
diff --git a/core/tests/karma.conf.ts b/core/tests/karma.conf.ts
--- a/core/tests/karma.conf.ts
+++ b/core/tests/karma.conf.ts
@@ -149,7 +149,14 @@ module.exports = function(config) {
           'node_modules',
           'third_party',
         ],
-        extensions: ['.ts', '.js', '.json', '.html', '.svg', '.png']
+        extensions: ['.ts', '.js', '.json', '.html', '.svg', '.png'],
+        alias: {
+          // This is needed because in app.constants.ts we need to import
+          // assets/constants.ts. We can't directly write import 'constants'
+          // because a module named 'constants' is defined in '@types/node'
+          // package.
+          'assets/constants': 'constants.ts'
+        }
       },
       devtool: 'inline-cheap-source-map',
       module: {
diff --git a/scripts/linters/js_ts_linter_test.py b/scripts/linters/js_ts_linter_test.py
--- a/scripts/linters/js_ts_linter_test.py
+++ b/scripts/linters/js_ts_linter_test.py
@@ -49,6 +49,10 @@
 LINTER_TESTS_DIR = os.path.join(os.getcwd(), 'scripts', 'linters', 'test_files')
 VALID_JS_FILEPATH = os.path.join(LINTER_TESTS_DIR, 'valid.js')
 VALID_TS_FILEPATH = os.path.join(LINTER_TESTS_DIR, 'valid.ts')
+VALID_APP_CONSTANTS_FILEPATH = os.path.join(
+    LINTER_TESTS_DIR, 'valid_app.constants.ts')
+VALID_APP_CONSTANTS_AJS_FILEPATH = os.path.join(
+    LINTER_TESTS_DIR, 'valid_app.constants.ajs.ts')
 VALID_CONSTANT_OUTSIDE_CLASS_FILEPATH = os.path.join(
     LINTER_TESTS_DIR, 'valid_constant_outside_class.constants.ts')
 VALID_CONSTANT_OUTSIDE_CLASS_AJS_FILEPATH = os.path.join(
@@ -71,6 +75,8 @@
     LINTER_TESTS_DIR, 'invalid_duplicate.constants.ts')
 INVALID_CONSTANT_AJS_FILEPATH = os.path.join(
     LINTER_TESTS_DIR, 'invalid_duplicate.constants.ajs.ts')
+INVALID_AS_CONST_CONSTANTS_FILEPATH = os.path.join(
+    LINTER_TESTS_DIR, 'invalid_as_const.constants.ts')
 INVALID_HTTP_CLIENT_FILEPATH = os.path.join(
     LINTER_TESTS_DIR, 'invalid_http_client_used.ts')
 INVALID_FORMATTED_COMMENT_FILEPATH = os.path.join(
@@ -377,6 +383,31 @@ def mock_compile_all_ts_files():
         expected_messages = ['Duplicate constant declaration found.']
         self.validate(lint_task_report, expected_messages, 1)
 
+    def test_as_const_in_constant_files(self):
+        def mock_compile_all_ts_files():
+            cmd = (
+                './node_modules/typescript/bin/tsc -outDir %s -allowJS %s '
+                '-lib %s -noImplicitUseStrict %s -skipLibCheck '
+                '%s -target %s -typeRoots %s %s typings/*') % (
+                    js_ts_linter.COMPILED_TYPESCRIPT_TMP_PATH +
+                    'scripts/linters/test_files/', 'true', 'es2017,dom', 'true',
+                    'true', 'es5', './node_modules/@types',
+                    INVALID_AS_CONST_CONSTANTS_FILEPATH)
+            subprocess.call(cmd, shell=True, stdout=subprocess.PIPE)
+
+        compile_all_ts_files_swap = self.swap(
+            js_ts_linter, 'compile_all_ts_files', mock_compile_all_ts_files)
+
+        with compile_all_ts_files_swap:
+            lint_task_report = js_ts_linter.JsTsLintChecksManager(
+                [], [INVALID_AS_CONST_CONSTANTS_FILEPATH], FILE_CACHE
+            ).perform_all_lint_checks()
+        shutil.rmtree(
+            js_ts_linter.COMPILED_TYPESCRIPT_TMP_PATH, ignore_errors=True)
+        expected_messages = [
+            'This constants file doesn\'t have \'as const\' at the end.']
+        self.validate(lint_task_report, expected_messages, 1)
+
     def test_check_constants_declaration_outside_class(self):
         def mock_compile_all_ts_files():
             cmd = (
@@ -402,6 +433,31 @@ def mock_compile_all_ts_files():
         expected_messages = ['SUCCESS  Constants declaration check passed']
         self.validate(lint_task_report, expected_messages, 1)
 
+    def test_check_app_constants_declaration(self):
+        def mock_compile_all_ts_files():
+            cmd = (
+                './node_modules/typescript/bin/tsc -outDir %s -allowJS %s '
+                '-lib %s -noImplicitUseStrict %s -skipLibCheck '
+                '%s -target %s -typeRoots %s %s %s typings/*') % (
+                    js_ts_linter.COMPILED_TYPESCRIPT_TMP_PATH +
+                    'scripts/linters/test_files/', 'true', 'es2017,dom', 'true',
+                    'true', 'es5', './node_modules/@types',
+                    VALID_APP_CONSTANTS_AJS_FILEPATH,
+                    VALID_APP_CONSTANTS_FILEPATH)
+            subprocess.call(cmd, shell=True, stdout=subprocess.PIPE)
+
+        compile_all_ts_files_swap = self.swap(
+            js_ts_linter, 'compile_all_ts_files', mock_compile_all_ts_files)
+
+        with compile_all_ts_files_swap:
+            lint_task_report = js_ts_linter.JsTsLintChecksManager(
+                [], [VALID_APP_CONSTANTS_FILEPATH], FILE_CACHE,
+            ).perform_all_lint_checks()
+        shutil.rmtree(
+            js_ts_linter.COMPILED_TYPESCRIPT_TMP_PATH, ignore_errors=True)
+        expected_messages = ['SUCCESS  Constants declaration check passed']
+        self.validate(lint_task_report, expected_messages, 1)
+
     def test_check_constants_declaration_in_non_constant_file(self):
         def mock_compile_all_ts_files():
             cmd = (
diff --git a/scripts/linters/test_files/invalid_as_const.constants.ts b/scripts/linters/test_files/invalid_as_const.constants.ts
new file mode 100644
--- /dev/null
+++ b/scripts/linters/test_files/invalid_as_const.constants.ts
@@ -0,0 +1,33 @@
+// Copyright 2020 The Oppia Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS-IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+/**
+ * @fileoverview Invalid syntax .ts file, used by scripts/linters/
+ * js_ts_linter_test.py. There is no 'as const' in this file.
+ */
+
+export const AdminPageConstants = {
+  ADMIN_ROLE_HANDLER_URL: '/adminrolehandler',
+  ADMIN_HANDLER_URL: '/adminhandler',
+  ADMIN_TOPICS_CSV_DOWNLOAD_HANDLER_URL: '/admintopicscsvdownloadhandler',
+  ADMIN_JOB_OUTPUT_URL_TEMPLATE: '/adminjoboutput?job_id=<jobId>',
+  ADMIN_TAB_URLS: {
+    ACTIVITIES: '#activities',
+    JOBS: '#jobs',
+    CONFIG: '#config',
+    ROLES: '#roles',
+    MISC: '#misc'
+  },
+  PROFILE_URL_TEMPLATE: '/profile/<username>'
+};
diff --git a/scripts/linters/test_files/invalid_duplicate.constants.ts b/scripts/linters/test_files/invalid_duplicate.constants.ts
--- a/scripts/linters/test_files/invalid_duplicate.constants.ts
+++ b/scripts/linters/test_files/invalid_duplicate.constants.ts
@@ -17,28 +17,18 @@
  * js_ts_linter_test.py. There are two constants in this file.
  */
 
-export class AdminPageConstants {
-  public static
-    ADMIN_ROLE_HANDLER_URL = '/adminrolehandler';
-
-  public static
-    ADMIN_ROLE_HANDLER_URL = '/adminrolehandler';
-
-  public static ADMIN_HANDLER_URL = '/adminhandler';
-  public static
-    ADMIN_TOPICS_CSV_DOWNLOAD_HANDLER_URL = '/admintopicscsvdownloadhandler';
-
-  public static
-    ADMIN_JOB_OUTPUT_URL_TEMPLATE = '/adminjoboutput?job_id=<jobId>';
-
-  public static ADMIN_TAB_URLS = {
+export const AdminPageConstants = {
+  ADMIN_ROLE_HANDLER_URL: '/adminrolehandler',
+  ADMIN_ROLE_HANDLER_URL: '/adminrolehandler',
+  ADMIN_HANDLER_URL: '/adminhandler',
+  ADMIN_TOPICS_CSV_DOWNLOAD_HANDLER_URL: '/admintopicscsvdownloadhandler',
+  ADMIN_JOB_OUTPUT_URL_TEMPLATE: '/adminjoboutput?job_id=<jobId>',
+  ADMIN_TAB_URLS: {
     ACTIVITIES: '#activities',
     JOBS: '#jobs',
     CONFIG: '#config',
     ROLES: '#roles',
     MISC: '#misc'
-  };
-
-  public static
-    PROFILE_URL_TEMPLATE = '/profile/<username>';
-}
+  },
+  PROFILE_URL_TEMPLATE: '/profile/<username>'
+} as const;
diff --git a/core/templates/app-type.constants.ts b/scripts/linters/test_files/valid_app.constants.ajs.ts
similarity index 76%
rename from core/templates/app-type.constants.ts
rename to scripts/linters/test_files/valid_app.constants.ajs.ts
--- a/core/templates/app-type.constants.ts
+++ b/scripts/linters/test_files/valid_app.constants.ajs.ts
@@ -13,10 +13,9 @@
 // limitations under the License.
 
 /**
- * @fileoverview Type definitions for the constants in the Oppia module.
+ * @fileoverview Valid syntax .ts file, used by scripts/linters/
+ * js_ts_linter_test.py.
  */
 
-export interface WARNING_TYPES_CONSTANT {
-    CRITICAL: string,
-    ERROR: string
-}
+angular.module('oppia').constant('ACTIVITY_STATUS_PRIVATE',
+  AppConstants.ACTIVITY_STATUS_PRIVATE);
diff --git a/scripts/linters/test_files/valid_app.constants.ts b/scripts/linters/test_files/valid_app.constants.ts
new file mode 100644
--- /dev/null
+++ b/scripts/linters/test_files/valid_app.constants.ts
@@ -0,0 +1,25 @@
+// Copyright 2019 The Oppia Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS-IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+/**
+ * @fileoverview Valid syntax .ts file, used by scripts/linters/
+ * js_ts_linter_test.py.
+ */
+
+import commonConstants from 'assets/constants';
+
+export const AppConstants = {
+  ...commonConstants,
+  ACTIVITY_STATUS_PRIVATE: 'private'
+} as const;
diff --git a/scripts/linters/test_files/valid_constant_outside_class.constants.ts b/scripts/linters/test_files/valid_constant_outside_class.constants.ts
--- a/scripts/linters/test_files/valid_constant_outside_class.constants.ts
+++ b/scripts/linters/test_files/valid_constant_outside_class.constants.ts
@@ -19,6 +19,6 @@
 
  const INTERACTION_SPECS = require('interactions/interaction_specs.json');
 
- export class InteractionSpecsConstants {
-   public static INTERACTION_SPECS = INTERACTION_SPECS;
- }
+ export const InteractionSpecsConstants = {
+   INTERACTION_SPECS: INTERACTION_SPECS
+ } as const;