diff --git a/lib/papi-dts/papi.d.ts b/lib/papi-dts/papi.d.ts index 5271315e53..e32f449b35 100644 --- a/lib/papi-dts/papi.d.ts +++ b/lib/papi-dts/papi.d.ts @@ -2487,10 +2487,14 @@ declare module 'papi-shared-types' { /** * Set the value of the specified project setting on this project. * + * Note: `setSetting` must call `papi.projectSettings.isValid` before allowing the setting + * change. + * * @param key The string id of the project setting to change * @param newSetting The value that is to be set to the project setting. * @returns Information that papi uses to interpret whether to send out updates. Defaults to * `true` (meaning send updates only for this data type). + * @throws If the setting validator failed. * @see {@link DataProviderUpdateInstructions} for more info on what to return */ setSetting: ( @@ -4709,6 +4713,24 @@ declare module 'renderer/hooks/papi-hooks/use-dialog-callback.hook' { } declare module 'shared/services/project-settings.service-model' { import { ProjectSettingNames, ProjectSettingTypes, ProjectTypes } from 'papi-shared-types'; + import { UnsubscriberAsync } from 'platform-bible-utils'; + /** Name prefix for registered commands that call project settings validators */ + export const CATEGORY_EXTENSION_PROJECT_SETTING_VALIDATOR = 'extensionProjectSettingValidator'; + export const projectSettingsServiceNetworkObjectName = 'ProjectSettingsService'; + export const projectSettingsServiceObjectToProxy: Readonly<{ + /** + * + * Registers a function that validates whether a new project setting value is allowed to be set. + * + * @param key The string id of the setting to validate + * @param validator Function to call to validate the new setting value + * @returns Unsubscriber that should be called whenever the providing extension is deactivated + */ + registerValidator: ( + key: ProjectSettingName, + validator: ProjectSettingValidator, + ) => Promise; + }>; /** * * Provides utility functions that project storage interpreters should call when handling project @@ -4730,11 +4752,11 @@ declare module 'shared/services/project-settings.service-model' { * @returns `true` if change is valid, `false` otherwise */ isValid( + key: ProjectSettingName, newValue: ProjectSettingTypes[ProjectSettingName], currentValue: ProjectSettingTypes[ProjectSettingName], - key: ProjectSettingName, - allChanges: SimultaneousProjectSettingsChanges, projectType: ProjectTypes, + allChanges?: SimultaneousProjectSettingsChanges, ): Promise; /** * Gets default value for a project setting @@ -4753,6 +4775,18 @@ declare module 'shared/services/project-settings.service-model' { key: ProjectSettingName, projectType: ProjectTypes, ): Promise; + /** + * + * Registers a function that validates whether a new project setting value is allowed to be set. + * + * @param key The string id of the setting to validate + * @param validator Function to call to validate the new setting value + * @returns Unsubscriber that should be called whenever the providing extension is deactivated + */ + registerValidator( + key: ProjectSettingName, + validatorCallback: ProjectSettingValidator, + ): Promise; } /** * All project settings changes being set in one batch @@ -4768,7 +4802,20 @@ declare module 'shared/services/project-settings.service-model' { currentValue: ProjectSettingTypes[ProjectSettingName]; }; }; - export const projectSettingsServiceNetworkObjectName = 'ProjectSettingsService'; + /** Function that validates whether a new project setting value should be allowed to be set */ + export type ProjectSettingValidator = ( + newValue: ProjectSettingTypes[ProjectSettingName], + currentValue: ProjectSettingTypes[ProjectSettingName], + allChanges: SimultaneousProjectSettingsChanges, + projectType: ProjectTypes, + ) => Promise; + /** + * Validators for all project settings. Keys are setting keys, values are functions to validate new + * settings + */ + export type AllProjectSettingsValidators = { + [ProjectSettingName in ProjectSettingNames]: ProjectSettingValidator; + }; } declare module '@papi/core' { /** Exporting empty object so people don't have to put 'type' in their import statements */ diff --git a/src/declarations/papi-shared-types.ts b/src/declarations/papi-shared-types.ts index c475b5b1a5..c50841fb8d 100644 --- a/src/declarations/papi-shared-types.ts +++ b/src/declarations/papi-shared-types.ts @@ -174,10 +174,14 @@ declare module 'papi-shared-types' { /** * Set the value of the specified project setting on this project. * + * Note: `setSetting` must call `papi.projectSettings.isValid` before allowing the setting + * change. + * * @param key The string id of the project setting to change * @param newSetting The value that is to be set to the project setting. * @returns Information that papi uses to interpret whether to send out updates. Defaults to * `true` (meaning send updates only for this data type). + * @throws If the setting validator failed. * @see {@link DataProviderUpdateInstructions} for more info on what to return */ setSetting: ( diff --git a/src/main/data/core-project-settings-info.data.ts b/src/main/data/core-project-settings-info.data.ts index 94843ebb45..f2fac0e858 100644 --- a/src/main/data/core-project-settings-info.data.ts +++ b/src/main/data/core-project-settings-info.data.ts @@ -1,4 +1,9 @@ +import { + AllProjectSettingsValidators, + ProjectSettingValidator, +} from '@shared/services/project-settings.service-model'; import { ProjectSettingNames, ProjectSettingTypes } from 'papi-shared-types'; +import { stringLength } from 'platform-bible-utils'; /** Information about one project setting */ type ProjectSettingInfo = { @@ -27,4 +32,27 @@ const coreProjectSettingsInfo: Partial = { 'platformScripture.versification': { default: 4 }, }; +// Based on https://github.com/paranext/paranext-core/blob/5c403e272b002ddd8970f735bc119f335c78c509/extensions/src/usfm-data-provider/index.d.ts#L401 +// Should be 123 characters long +// todo Check that it only includes 1 or 0 +export const booksPresentSettingsValidator: ProjectSettingValidator< + 'platformScripture.booksPresent' +> = async (newValue: string): Promise => { + return stringLength(newValue) === 123; +}; + +// Based on https://github.com/paranext/paranext-core/blob/5c403e272b002ddd8970f735bc119f335c78c509/extensions/src/usfm-data-provider/index.d.ts#L391 +// There are 7 options in the enum +export const versificationSettingsValidator: ProjectSettingValidator< + 'platformScripture.versification' +> = async (newValue: number): Promise => { + return newValue >= 0 && newValue <= 6; +}; + +/** Info about all settings built into core. Does not contain info for extensions' settings */ +export const coreProjectSettingsValidators: Partial = { + 'platformScripture.booksPresent': booksPresentSettingsValidator, + 'platformScripture.versification': versificationSettingsValidator, +}; + export default coreProjectSettingsInfo; diff --git a/src/main/services/project-settings.service-host.test.ts b/src/main/services/project-settings.service-host.test.ts index 92fdbbf481..307a36ceb3 100644 --- a/src/main/services/project-settings.service-host.test.ts +++ b/src/main/services/project-settings.service-host.test.ts @@ -1,6 +1,14 @@ import { testingProjectSettingsService } from '@main/services/project-settings.service-host'; +import { ProjectSettingValidator } from '@shared/services/project-settings.service-model'; +jest.mock('@shared/services/network.service', () => ({ + ...jest.requireActual('@shared/services/network.service'), + registerRequestHandler: () => { + return {}; + }, +})); jest.mock('@main/data/core-project-settings-info.data', () => ({ + ...jest.requireActual('@main/data/core-project-settings-info.data'), __esModule: true, default: { 'platform.fullName': { default: '%test_project_full_name_missing%' }, @@ -10,19 +18,34 @@ jest.mock('@main/data/core-project-settings-info.data', () => ({ }, // Not present! Should throw error 'platformScripture.versification': { default: 1629326 }, }, + coreProjectSettingsValidators: { + 'platform.language': async (newValue: string): Promise => { + return newValue === 'eng' || newValue === 'fre'; + }, + }, })); describe('isValid', () => { - it('should return true always - TEMP. TODO: Fix when we implement validation #511', async () => { + it('should return true', async () => { + const projectSettingKey = 'platform.language'; const isSettingChangeValid = await testingProjectSettingsService.isValid( - '', - '', - 'platform.fullName', - {}, + projectSettingKey, + 'eng', + '%test_project_language_missing%', 'ParatextStandard', ); expect(isSettingChangeValid).toBe(true); }); + it('should return false', async () => { + const projectSettingKey = 'platform.language'; + const isSettingChangeValid = await testingProjectSettingsService.isValid( + projectSettingKey, + 'ger', + '%test_project_language_missing%', + 'ParatextStandard', + ); + expect(isSettingChangeValid).toBe(false); + }); }); describe('getDefault', () => { @@ -42,3 +65,17 @@ describe('getDefault', () => { ).rejects.toThrow(new RegExp(`default value for project setting ${projectSettingKey}`)); }); }); + +describe('registerValidator', () => { + it('should resolve', async () => { + const projectSettingKey = 'platform.fullName'; + const fullNameSettingsValidator: ProjectSettingValidator< + 'platform.fullName' + > = async (): Promise => { + return true; + }; + await expect( + testingProjectSettingsService.registerValidator(projectSettingKey, fullNameSettingsValidator), + ).resolves.toStrictEqual({}); + }); +}); diff --git a/src/main/services/project-settings.service-host.ts b/src/main/services/project-settings.service-host.ts index 2a8348492f..bd7154ec23 100644 --- a/src/main/services/project-settings.service-host.ts +++ b/src/main/services/project-settings.service-host.ts @@ -1,12 +1,19 @@ +import * as networkService from '@shared/services/network.service'; import coreProjectSettingsInfo, { AllProjectSettingsInfo, + coreProjectSettingsValidators, } from '@main/data/core-project-settings-info.data'; import networkObjectService from '@shared/services/network-object.service'; import { + CATEGORY_EXTENSION_PROJECT_SETTING_VALIDATOR, IProjectSettingsService, + SimultaneousProjectSettingsChanges, projectSettingsServiceNetworkObjectName, + projectSettingsServiceObjectToProxy, } from '@shared/services/project-settings.service-model'; +import { serializeRequestType } from '@shared/utils/util'; import { ProjectSettingNames, ProjectSettingTypes, ProjectTypes } from 'papi-shared-types'; +import { includes } from 'platform-bible-utils'; /** * Our internal list of project settings information for each setting. Theoretically this should not @@ -35,9 +42,34 @@ async function initialize(): Promise { return initializationPromise; } -// TODO: Implement validators in https://github.com/paranext/paranext-core/issues/511 -async function isValid(): Promise { - return true; +async function isValid( + key: ProjectSettingName, + newValue: ProjectSettingTypes[ProjectSettingName], + currentValue: ProjectSettingTypes[ProjectSettingName], + projectType: ProjectTypes, + allChanges?: SimultaneousProjectSettingsChanges, +): Promise { + if (key in coreProjectSettingsValidators) { + const projectSettingValidator = coreProjectSettingsValidators[key]; + if (projectSettingValidator) + return projectSettingValidator(newValue, currentValue, allChanges ?? {}, projectType); + // If key exists in coreProjectSettingsValidators but there is no validator, let the change go through + return true; + } + try { + return networkService.request( + serializeRequestType(CATEGORY_EXTENSION_PROJECT_SETTING_VALIDATOR, key), + newValue, + currentValue, + allChanges ?? {}, + projectType, + ); + } catch (error) { + // If there is no validator just let the change go through + const missingValidatorMsg = `No handler was found to process the request of type`; + if (includes(`${error}`, missingValidatorMsg)) return true; + throw error; + } } async function getDefault( @@ -55,9 +87,11 @@ async function getDefault( return projectSettingInfo.default; } +const { registerValidator } = projectSettingsServiceObjectToProxy; const projectSettingsService: IProjectSettingsService = { isValid, getDefault, + registerValidator, }; /** This is an internal-only export for testing purposes, and should not be used in development */ diff --git a/src/shared/services/project-settings.service-model.ts b/src/shared/services/project-settings.service-model.ts index 14ee71c864..e0eef065db 100644 --- a/src/shared/services/project-settings.service-model.ts +++ b/src/shared/services/project-settings.service-model.ts @@ -1,4 +1,24 @@ +import { serializeRequestType } from '@shared/utils/util'; +import * as networkService from '@shared/services/network.service'; import { ProjectSettingNames, ProjectSettingTypes, ProjectTypes } from 'papi-shared-types'; +import { UnsubscriberAsync } from 'platform-bible-utils'; + +/** Name prefix for registered commands that call project settings validators */ +export const CATEGORY_EXTENSION_PROJECT_SETTING_VALIDATOR = 'extensionProjectSettingValidator'; + +export const projectSettingsServiceNetworkObjectName = 'ProjectSettingsService'; +export const projectSettingsServiceObjectToProxy = Object.freeze({ + /** JSDOC DESTINATION projectSettingsServiceRegisterValidator */ + registerValidator: async ( + key: ProjectSettingName, + validator: ProjectSettingValidator, + ): Promise => { + return networkService.registerRequestHandler( + serializeRequestType(CATEGORY_EXTENSION_PROJECT_SETTING_VALIDATOR, key), + validator, + ); + }, +}); /** * JSDOC SOURCE projectSettingsService @@ -22,11 +42,11 @@ export interface IProjectSettingsService { * @returns `true` if change is valid, `false` otherwise */ isValid( + key: ProjectSettingName, newValue: ProjectSettingTypes[ProjectSettingName], currentValue: ProjectSettingTypes[ProjectSettingName], - key: ProjectSettingName, - allChanges: SimultaneousProjectSettingsChanges, projectType: ProjectTypes, + allChanges?: SimultaneousProjectSettingsChanges, ): Promise; /** * Gets default value for a project setting @@ -45,6 +65,19 @@ export interface IProjectSettingsService { key: ProjectSettingName, projectType: ProjectTypes, ): Promise; + /** + * JSDOC SOURCE projectSettingsServiceRegisterValidator + * + * Registers a function that validates whether a new project setting value is allowed to be set. + * + * @param key The string id of the setting to validate + * @param validator Function to call to validate the new setting value + * @returns Unsubscriber that should be called whenever the providing extension is deactivated + */ + registerValidator( + key: ProjectSettingName, + validatorCallback: ProjectSettingValidator, + ): Promise; } /** @@ -61,5 +94,18 @@ export type SimultaneousProjectSettingsChanges = { currentValue: ProjectSettingTypes[ProjectSettingName]; }; }; +/** Function that validates whether a new project setting value should be allowed to be set */ +export type ProjectSettingValidator = ( + newValue: ProjectSettingTypes[ProjectSettingName], + currentValue: ProjectSettingTypes[ProjectSettingName], + allChanges: SimultaneousProjectSettingsChanges, + projectType: ProjectTypes, +) => Promise; -export const projectSettingsServiceNetworkObjectName = 'ProjectSettingsService'; +/** + * Validators for all project settings. Keys are setting keys, values are functions to validate new + * settings + */ +export type AllProjectSettingsValidators = { + [ProjectSettingName in ProjectSettingNames]: ProjectSettingValidator; +}; diff --git a/src/shared/services/project-settings.service.ts b/src/shared/services/project-settings.service.ts index 5040d78a06..b9fec0d3f9 100644 --- a/src/shared/services/project-settings.service.ts +++ b/src/shared/services/project-settings.service.ts @@ -1,8 +1,10 @@ import { projectSettingsServiceNetworkObjectName, IProjectSettingsService, + projectSettingsServiceObjectToProxy, } from '@shared/services/project-settings.service-model'; import networkObjectService from '@shared/services/network-object.service'; +import { createSyncProxyForAsyncObject } from 'platform-bible-utils'; let networkObject: IProjectSettingsService; let initializationPromise: Promise; @@ -31,15 +33,9 @@ async function initialize(): Promise { return initializationPromise; } -const projectSettingsService: IProjectSettingsService = { - async getDefault(key, projectType) { - await initialize(); - return networkObject.getDefault(key, projectType); - }, - async isValid(newValue, currentValue, key, allChanges, projectType) { - await initialize(); - return networkObject.isValid(newValue, currentValue, key, allChanges, projectType); - }, -}; +const projectSettingsService = createSyncProxyForAsyncObject(async () => { + await initialize(); + return networkObject; +}, projectSettingsServiceObjectToProxy); export default projectSettingsService;