diff --git a/jsapp/js/components/permissions/permConstants.ts b/jsapp/js/components/permissions/permConstants.ts index 2b3fd3d37f..6d3198a21f 100644 --- a/jsapp/js/components/permissions/permConstants.ts +++ b/jsapp/js/components/permissions/permConstants.ts @@ -81,21 +81,50 @@ type CheckboxNameRegular = | 'submissionsEdit' | 'submissionsValidate' | 'submissionsDelete'; -/** Names of checkboxes for partial permissions (the counterparts). */ +/** Names of checkboxes for "by users" partial permissions. */ export type CheckboxNamePartialByUsers = | 'submissionsViewPartialByUsers' | 'submissionsEditPartialByUsers' | 'submissionsValidatePartialByUsers' | 'submissionsDeletePartialByUsers'; +/** Names of checkboxes for "by responses" partial permissions. */ +export type CheckboxNamePartialByResponses = + | 'submissionsViewPartialByResponses' + | 'submissionsEditPartialByResponses' + | 'submissionsValidatePartialByResponses' + | 'submissionsDeletePartialByResponses'; /** All checkboxes names combined. */ -export type CheckboxNameAll = CheckboxNameRegular | CheckboxNamePartialByUsers; -/** Name of lists of usernames for a partial permissions checkboxes. */ +export type CheckboxNameAll = + | CheckboxNameRegular + | CheckboxNamePartialByUsers + | CheckboxNamePartialByResponses; + +/** Name of lists of usernames for "by users" partial permissions checkboxes. */ export type PartialByUsersListName = | 'submissionsViewPartialByUsersList' | 'submissionsEditPartialByUsersList' | 'submissionsDeletePartialByUsersList' | 'submissionsValidatePartialByUsersList'; +/** + * Name of select used by user to choose question for "by responses" partial + * permissions checkboxes. + */ +export type PartialByResponsesQuestionName = + | 'submissionsViewPartialByResponsesQuestion' + | 'submissionsEditPartialByResponsesQuestion' + | 'submissionsDeletePartialByResponsesQuestion' + | 'submissionsValidatePartialByResponsesQuestion'; +/** + * Name of textbox used by user to type the condition value for "by responses" + * partial permissions checkboxes. + */ +export type PartialByResponsesValueName = + | 'submissionsViewPartialByResponsesValue' + | 'submissionsEditPartialByResponsesValue' + | 'submissionsDeletePartialByResponsesValue' + | 'submissionsValidatePartialByResponsesValue'; + /** * This list contains the names of all the checkboxes in userAssetPermsEditor. * Every one of them is strictly connected to a permission, see the pairs at @@ -108,17 +137,22 @@ export const CHECKBOX_NAMES = createEnum([ 'submissionsAdd', 'submissionsView', 'submissionsViewPartialByUsers', + 'submissionsViewPartialByResponses', 'submissionsEdit', 'submissionsEditPartialByUsers', + 'submissionsEditPartialByResponses', 'submissionsValidate', 'submissionsValidatePartialByUsers', + 'submissionsValidatePartialByResponses', 'submissionsDelete', 'submissionsDeletePartialByUsers', + 'submissionsDeletePartialByResponses', ]) as {[P in CheckboxNameAll]: CheckboxNameAll}; Object.freeze(CHECKBOX_NAMES); /** - * This is a map of pairs that connects a partial checkbox to a permission. + * This is a map of pairs that connects a partial "by users" checkbox to + * a matching permission. * * NOTE: a partial checkbox is using a "partial_submissions" permission, but * in the array of de facto permissions it is using these ones. So for example @@ -127,7 +161,7 @@ Object.freeze(CHECKBOX_NAMES); * and a view_submissions permission - meaning that "Joe can view submissions, * but only for this limited list of users". */ -export const PARTIAL_PERM_PAIRS: { +export const PARTIAL_BY_USERS_PERM_PAIRS: { [key in CheckboxNamePartialByUsers]: PermissionCodename; } = { submissionsViewPartialByUsers: 'view_submissions', @@ -135,7 +169,21 @@ export const PARTIAL_PERM_PAIRS: { submissionsValidatePartialByUsers: 'validate_submissions', submissionsDeletePartialByUsers: 'delete_submissions', }; -Object.freeze(PARTIAL_PERM_PAIRS); +Object.freeze(PARTIAL_BY_USERS_PERM_PAIRS); + +/** + * This is a map of pairs that connects a partial "by responses" checkbox to + * a matching permission. + */ +export const PARTIAL_BY_RESPONSES_PERM_PAIRS: { + [key in CheckboxNamePartialByResponses]: PermissionCodename; +} = { + submissionsViewPartialByResponses: 'view_submissions', + submissionsEditPartialByResponses: 'change_submissions', + submissionsValidatePartialByResponses: 'validate_submissions', + submissionsDeletePartialByResponses: 'delete_submissions', +}; +Object.freeze(PARTIAL_BY_RESPONSES_PERM_PAIRS); /** * This is a map of pairs that connect a checkbox name to a permission name. @@ -149,12 +197,16 @@ export const CHECKBOX_PERM_PAIRS: { submissionsAdd: 'add_submissions', submissionsView: 'view_submissions', submissionsViewPartialByUsers: 'partial_submissions', + submissionsViewPartialByResponses: 'partial_submissions', submissionsEdit: 'change_submissions', submissionsEditPartialByUsers: 'partial_submissions', + submissionsEditPartialByResponses: 'partial_submissions', submissionsValidate: 'validate_submissions', submissionsValidatePartialByUsers: 'partial_submissions', + submissionsValidatePartialByResponses: 'partial_submissions', submissionsDelete: 'delete_submissions', submissionsDeletePartialByUsers: 'partial_submissions', + submissionsDeletePartialByResponses: 'partial_submissions', }; Object.freeze(CHECKBOX_PERM_PAIRS); @@ -164,6 +216,8 @@ Object.freeze(CHECKBOX_PERM_PAIRS); */ export const PARTIAL_IMPLIED_CHECKBOX_PAIRS = { [CHECKBOX_NAMES.submissionsEditPartialByUsers]: CHECKBOX_NAMES.submissionsAdd, + [CHECKBOX_NAMES.submissionsEditPartialByResponses]: + CHECKBOX_NAMES.submissionsAdd, }; Object.freeze(PARTIAL_IMPLIED_CHECKBOX_PAIRS); @@ -180,19 +234,39 @@ export const CHECKBOX_LABELS: {[key in CheckboxNameAll]: string} = { submissionsAdd: t('Add submissions'), submissionsView: t('View submissions'), submissionsViewPartialByUsers: t('View submissions only from specific users'), + submissionsViewPartialByResponses: t('View submissions based on a condition'), submissionsEdit: t('Edit submissions'), submissionsEditPartialByUsers: t('Edit submissions only from specific users'), + submissionsEditPartialByResponses: t('Edit submissions based on a condition'), submissionsValidate: t('Validate submissions'), submissionsValidatePartialByUsers: t( 'Validate submissions only from specific users' ), + submissionsValidatePartialByResponses: t( + 'Validate submissions based on a condition' + ), submissionsDelete: t('Delete submissions'), submissionsDeletePartialByUsers: t( 'Delete submissions only from specific users' ), + submissionsDeletePartialByResponses: t( + 'Delete submissions based on a condition' + ), }; Object.freeze(CHECKBOX_LABELS); -export const PARTIAL_BY_USERS_DEFAULT_LABEL = t( +export const PARTIAL_BY_USERS_LABEL = t( 'Act on submissions only from specific users' ); + +export const PARTIAL_BY_RESPONSES_LABEL = t( + 'Act on submissions based on a condition' +); + +// To be used when there are multiple filters in single permission - e.g. it has +// both "by users" and "by responses" defined. +export const PARTIAL_BY_MULTIPLE_LABEL = t( + 'Act on submissions based on multiple conditions' +); + +export const CHECKBOX_DISABLED_SUFFIX = 'Disabled'; \ No newline at end of file diff --git a/jsapp/js/components/permissions/permParser.mocks.ts b/jsapp/js/components/permissions/permParser.mocks.ts index cc2c0b9bbf..ad99045635 100644 --- a/jsapp/js/components/permissions/permParser.mocks.ts +++ b/jsapp/js/components/permissions/permParser.mocks.ts @@ -1,4 +1,8 @@ -import type {PermissionsConfigResponse} from 'js/dataInterface'; +import type { + PermissionsConfigResponse, + PaginatedResponse, + PermissionResponse, +} from 'js/dataInterface'; /** * Mock permissions endpoints responses for tests. @@ -111,7 +115,7 @@ const permissions: PermissionsConfigResponse = { }; // /api/v2/assets//permission-assignments/ -const assetWithAnonymousUser = { +const assetWithAnonymousUser: PaginatedResponse = { count: 7, next: null, previous: null, @@ -162,7 +166,7 @@ const assetWithAnonymousUser = { }; // /api/v2/assets//permission-assignments/ -const assetWithMultipleUsers = { +const assetWithMultipleUsers: PaginatedResponse = { count: 9, next: null, previous: null, @@ -231,7 +235,7 @@ const assetWithMultipleUsers = { }; // /api/v2/assets//permission-assignments/ -const assetWithPartial = { +const assetWithPartial: PaginatedResponse = { count: 8, next: null, previous: null, @@ -288,7 +292,74 @@ const assetWithPartial = { url: '/api/v2/permissions/view_submissions/', filters: [{_submitted_by: {$in: ['john', 'olivier']}}], }, + { + url: '/api/v2/permissions/change_submissions/', + filters: [{Where_are_you_from: 'Poland'}], + }, + ], + }, + ], +}; + +// /api/v2/assets//permission-assignments/ +const assetWithMultiplePartial: PaginatedResponse = { + count: 3, + next: null, + previous: null, + results: [ + { + url: '/api/v2/assets/abc123/permission-assignments/asd123/', + user: '/api/v2/users/gwyneth/', + permission: '/api/v2/permissions/add_submissions/', + label: 'Add submissions', + }, + { + url: '/api/v2/assets/abc123/permission-assignments/vbn123/', + user: '/api/v2/users/gwyneth/', + permission: '/api/v2/permissions/partial_submissions/', + partial_permissions: [ + // This permission is the AND one, which is the only case supported by + // Front-end code + { + url: '/api/v2/permissions/view_submissions/', + filters: [ + { + Where_are_you_from: 'Poland', + _submitted_by: {$in: ['dave', 'krzysztof']}, + }, + ], + }, + { + url: '/api/v2/permissions/change_submissions/', + filters: [{Your_color: 'blue'}], + }, + { + url: '/api/v2/permissions/delete_submissions/', + filters: [{_submitted_by: {$in: ['kate', 'joshua']}}], + }, + // This permission is the OR one, which is not supported by Front-end + // code and should be treated as AND + { + url: '/api/v2/permissions/validate_submissions/', + filters: [ + {What_is_your_fav_animal: 'Racoon'}, + {_submitted_by: 'zachary'}, + ], + }, ], + label: { + default: 'Act on submissions only from specific users', + view_submissions: 'View submissions only from specific users', + change_submissions: 'Edit submissions only from specific users', + delete_submissions: 'Delete submissions only from specific users', + validate_submissions: 'Validate submissions only from specific users', + }, + }, + { + url: '/api/v2/assets/abc123/permission-assignments/zxc123/', + user: '/api/v2/users/gwyneth/', + permission: '/api/v2/permissions/view_asset/', + label: 'View form', }, ], }; @@ -298,4 +369,5 @@ export const endpoints = { assetWithAnonymousUser, assetWithMultipleUsers, assetWithPartial, + assetWithMultiplePartial, }; diff --git a/jsapp/js/components/permissions/permParser.tests.ts b/jsapp/js/components/permissions/permParser.tests.ts index 6581e7a375..7081d4bce5 100644 --- a/jsapp/js/components/permissions/permParser.tests.ts +++ b/jsapp/js/components/permissions/permParser.tests.ts @@ -2,6 +2,7 @@ import { parseFormData, buildFormData, parseBackendData, + removeImpliedPerms, parseUserWithPermsList, sortParseBackendOutput, } from './permParser'; @@ -66,6 +67,105 @@ describe('permParser', () => { }); }); + describe('removeImpliedPerms', () => { + it('should remove implied non-partial permissions', () => { + const cleanedUpOutput = removeImpliedPerms([ + // Delete submissions is the one that gives/implies `view_submissions` + // and `view_asset` + { + user: '/api/v2/users/joe/', + permission: '/api/v2/permissions/delete_submissions/', + }, + { + user: '/api/v2/users/joe/', + permission: '/api/v2/permissions/view_submissions/', + }, + { + user: '/api/v2/users/joe/', + permission: '/api/v2/permissions/view_asset/', + }, + ]); + + chai.expect(cleanedUpOutput).to.deep.equal([ + { + user: '/api/v2/users/joe/', + permission: '/api/v2/permissions/delete_submissions/', + }, + ]); + }); + + it('should remove implied partial permissions', () => { + const cleanedUpOutput = removeImpliedPerms([ + { + user: '/api/v2/users/gwyneth/', + permission: '/api/v2/permissions/partial_submissions/', + partial_permissions: [ + { + url: '/api/v2/permissions/add_submissions/', + filters: [ + { + Where_is_it: 'North', + _submitted_by: 'georgia', + }, + ], + }, + { + url: '/api/v2/permissions/view_submissions/', + filters: [ + { + Where_is_it: 'South', + _submitted_by: { + $in: ['josh', 'bob'], + }, + }, + { + Where_is_it: 'North', + _submitted_by: 'georgia', + }, + ], + }, + { + url: '/api/v2/permissions/change_submissions/', + filters: [ + { + Where_is_it: 'North', + _submitted_by: 'georgia', + }, + ], + }, + ], + }, + ]); + + chai.expect(cleanedUpOutput).to.deep.equal([ + { + user: '/api/v2/users/gwyneth/', + permission: '/api/v2/permissions/partial_submissions/', + partial_permissions: [ + { + url: '/api/v2/permissions/view_submissions/', + filters: [ + { + _submitted_by: {$in: ['josh', 'bob']}, + Where_is_it: 'South', + }, + ], + }, + { + url: '/api/v2/permissions/change_submissions/', + filters: [ + { + _submitted_by: 'georgia', + Where_is_it: 'North', + }, + ], + }, + ], + }, + ]); + }); + }); + describe('sortParseBackendOutput', () => { it('should sort alphabetically with owner always first', () => { const sortedOutput = sortParseBackendOutput([ @@ -182,7 +282,6 @@ describe('permParser', () => { chai.expect(built).to.deep.equal({ username: 'eric', - formView: true, submissionsView: true, }); }); @@ -197,9 +296,11 @@ describe('permParser', () => { chai.expect(built).to.deep.equal({ username: 'tessa', - formView: true, submissionsViewPartialByUsers: true, submissionsViewPartialByUsersList: ['john', 'olivier'], + submissionsEditPartialByResponses: true, + submissionsEditPartialByResponsesQuestion: 'Where_are_you_from', + submissionsEditPartialByResponsesValue: 'Poland', }); }); @@ -228,6 +329,79 @@ describe('permParser', () => { }, ]); }); + + it('should build proper form data for multiple partial permissions', () => { + const testUser = 'gwyneth'; + + const usersWithPerms = parseBackendData( + endpoints.assetWithMultiplePartial.results, + endpoints.assetWithMultiplePartial.results[0].user + ); + + // Get testUser permissions + const testUserPerms = + usersWithPerms.find((item) => item.user.name === testUser) + ?.permissions || []; + + // Build the data again for the testUser + const builtFormData = buildFormData(testUserPerms, testUser); + + chai.expect(builtFormData).to.deep.equal({ + username: 'gwyneth', + submissionsAdd: true, + submissionsViewPartialByUsers: true, + submissionsViewPartialByUsersList: ['dave', 'krzysztof'], + submissionsViewPartialByResponses: true, + submissionsViewPartialByResponsesQuestion: 'Where_are_you_from', + submissionsViewPartialByResponsesValue: 'Poland', + submissionsEditPartialByResponses: true, + submissionsEditPartialByResponsesQuestion: 'Your_color', + submissionsEditPartialByResponsesValue: 'blue', + submissionsDeletePartialByUsers: true, + submissionsDeletePartialByUsersList: ['kate', 'joshua'], + submissionsValidatePartialByUsers: true, + submissionsValidatePartialByUsersList: ['zachary'], + submissionsValidatePartialByResponses: true, + submissionsValidatePartialByResponsesQuestion: + 'What_is_your_fav_animal', + submissionsValidatePartialByResponsesValue: 'Racoon', + }); + }); + + it('should work with "by responses" permission with empty value', () => { + const parsed = parseBackendData( + [ + { + url: '/api/v2/assets/abc123/permission-assignments/ghi789/', + user: '/api/v2/users/joe/', + permission: '/api/v2/permissions/manage_asset/', + label: 'Manage asset', + }, + { + url: '/api/v2/assets/abc123/permission-assignments/def456/', + user: '/api/v2/users/gwyneth/', + permission: '/api/v2/permissions/partial_submissions/', + label: 'Partial submissions', + partial_permissions: [ + { + url: '/api/v2/permissions/view_submissions/', + filters: [{What_is_up: ''}], + }, + ], + }, + ], + '/api/v2/users/joe/' + ); + + const built = buildFormData(parsed[1].permissions, 'gwyneth'); + + chai.expect(built).to.deep.equal({ + username: 'gwyneth', + submissionsViewPartialByResponses: true, + submissionsViewPartialByResponsesQuestion: 'What_is_up', + submissionsViewPartialByResponsesValue: '', + }); + }); }); describe('parseFormData', () => { @@ -256,16 +430,54 @@ describe('permParser', () => { ]); }); - it('should add partial_permissions property for partial submissions permission', () => { + it('should add partial_permissions with merged filters for identical partial permission', () => { const parsed = parseFormData({ username: 'leszek', formView: true, formEdit: false, - submissionsView: true, + submissionsView: false, + submissionsViewPartialByUsers: true, + submissionsViewPartialByUsersList: ['john', 'olivier', 'eric'], + submissionsViewPartialByResponses: true, + submissionsViewPartialByResponsesQuestion: 'Where_are_you_from', + submissionsViewPartialByResponsesValue: 'Poland', + submissionsAdd: false, + submissionsEdit: false, + submissionsValidate: false, + }); + + chai.expect(parsed).to.deep.equal([ + { + user: '/api/v2/users/leszek/', + permission: '/api/v2/permissions/partial_submissions/', + partial_permissions: [ + { + url: '/api/v2/permissions/view_submissions/', + filters: [ + { + _submitted_by: {$in: ['john', 'olivier', 'eric']}, + Where_are_you_from: 'Poland', + }, + ], + }, + ], + }, + ]); + }); + + it('should add separate partial_permissions for different partial permission', () => { + const parsed = parseFormData({ + username: 'leszek', + formView: true, + formEdit: false, + submissionsView: false, submissionsViewPartialByUsers: true, submissionsViewPartialByUsersList: ['john', 'olivier', 'eric'], submissionsAdd: false, submissionsEdit: false, + submissionsEditPartialByResponses: true, + submissionsEditPartialByResponsesQuestion: 'Where_are_you_from', + submissionsEditPartialByResponsesValue: 'Poland', submissionsValidate: false, }); @@ -278,6 +490,35 @@ describe('permParser', () => { url: '/api/v2/permissions/view_submissions/', filters: [{_submitted_by: {$in: ['john', 'olivier', 'eric']}}], }, + { + url: '/api/v2/permissions/change_submissions/', + filters: [{Where_are_you_from: 'Poland'}], + }, + ], + }, + ]); + }); + + it('should allow partial "by responses" with empty value', () => { + const parsed = parseFormData({ + username: 'leszek', + formView: true, + formEdit: false, + submissionsView: false, + submissionsViewPartialByResponses: true, + submissionsViewPartialByResponsesQuestion: 'What_is_up', + submissionsViewPartialByResponsesValue: '', + }); + + chai.expect(parsed).to.deep.equal([ + { + user: '/api/v2/users/leszek/', + permission: '/api/v2/permissions/partial_submissions/', + partial_permissions: [ + { + url: '/api/v2/permissions/view_submissions/', + filters: [{What_is_up: ''}], + }, ], }, ]); @@ -380,6 +621,10 @@ describe('permParser', () => { url: '/api/v2/permissions/view_submissions/', filters: [{_submitted_by: {$in: ['john', 'olivier']}}], }, + { + url: '/api/v2/permissions/change_submissions/', + filters: [{Where_are_you_from: 'Poland'}], + }, ], }, ]); diff --git a/jsapp/js/components/permissions/permParser.ts b/jsapp/js/components/permissions/permParser.ts index 8ba75be8f0..471b44e818 100644 --- a/jsapp/js/components/permissions/permParser.ts +++ b/jsapp/js/components/permissions/permParser.ts @@ -1,8 +1,15 @@ +import isEqual from 'lodash.isequal'; +import clonedeep from 'lodash.clonedeep'; import permConfig from './permConfig'; -import type {PermissionCodename} from './permConstants'; +import type { + PermissionCodename, + CheckboxNameAll, + CheckboxNamePartialByUsers, + CheckboxNamePartialByResponses, +} from './permConstants'; import { - PARTIAL_PERM_PAIRS, - CHECKBOX_NAMES, + PARTIAL_BY_USERS_PERM_PAIRS, + PARTIAL_BY_RESPONSES_PERM_PAIRS, CHECKBOX_PERM_PAIRS, } from './permConstants'; import {buildUserUrl, getUsernameFromUrl, ANON_USERNAME} from 'js/users/utils'; @@ -10,21 +17,19 @@ import type { PermissionResponse, PermissionBase, PartialPermission, + PartialPermissionFilter, } from 'js/dataInterface'; import { - getPartialByUsersListName, - getPartialByUsersCheckboxName, getCheckboxNameByPermission, + getPartialByUsersCheckboxName, + getPartialByUsersListName, + getPartialByResponsesCheckboxName, + getPartialByResponsesQuestionName, + getPartialByResponsesValueName, + getPartialByUsersFilterList, + getPartialByResponsesFilter, } from './utils'; -export interface UserPerm { - /** Url of given permission instance (permission x user). */ - url: string; - /** Url of given permission type. */ - permission: string; - partial_permissions?: PartialPermission[]; -} - export interface PermsFormData { /** Who give permissions to */ username: string; @@ -35,15 +40,27 @@ export interface PermsFormData { submissionsView?: boolean; submissionsViewPartialByUsers?: boolean; submissionsViewPartialByUsersList?: string[]; + submissionsViewPartialByResponses?: boolean; + submissionsViewPartialByResponsesQuestion?: string; + submissionsViewPartialByResponsesValue?: string; submissionsEdit?: boolean; submissionsEditPartialByUsers?: boolean; submissionsEditPartialByUsersList?: string[]; + submissionsEditPartialByResponses?: boolean; + submissionsEditPartialByResponsesQuestion?: string; + submissionsEditPartialByResponsesValue?: string; submissionsDelete?: boolean; submissionsDeletePartialByUsers?: boolean; submissionsDeletePartialByUsersList?: string[]; + submissionsDeletePartialByResponses?: boolean; + submissionsDeletePartialByResponsesQuestion?: string; + submissionsDeletePartialByResponsesValue?: string; submissionsValidate?: boolean; submissionsValidatePartialByUsers?: boolean; submissionsValidatePartialByUsersList?: string[]; + submissionsValidatePartialByResponses?: boolean; + submissionsValidatePartialByResponsesQuestion?: string; + submissionsValidatePartialByResponsesValue?: string; } export interface UserWithPerms { @@ -56,7 +73,7 @@ export interface UserWithPerms { isOwner: boolean; }; /** A list of permissions for that user. */ - permissions: UserPerm[]; + permissions: PermissionResponse[]; } /** @@ -114,7 +131,9 @@ function buildBackendPerm( } /** - * Removes contradictory permissions from the parsed list of BackendPerms. + * Removes contradictory permissions from the parsed list of BackendPerms. This + * is mostly needed for safety reasons, cleaning up some permissions that don't + * make sense. */ function removeContradictoryPerms(parsed: PermissionBase[]): PermissionBase[] { const contraPerms = new Set(); @@ -131,9 +150,62 @@ function removeContradictoryPerms(parsed: PermissionBase[]): PermissionBase[] { } /** - * Removes implied permissions from the parsed list of BackendPerms. + * Removes all redundant (implied) filters and permissions from a list of + * partial permissions. + */ +export function removeImpliedPartialPerms( + partialPerms: PartialPermission[] +): PartialPermission[] { + // Step 1. Don't mutate things + let perms = clonedeep(partialPerms); + + // Step 2. Gather all implied permissions x filters pairs. + const redundantFilters: Array<{ + permUrl: string; + filter: PartialPermissionFilter; + }> = []; + perms.forEach((partialPerm) => { + const permDef = permConfig.getPermission(partialPerm.url); + permDef?.implied.forEach((impliedPerm) => { + partialPerm.filters.forEach((filter) => { + redundantFilters.push({ + permUrl: impliedPerm, + filter: filter, + }); + }); + }); + }); + + // Step 3. Traverse through partial permissions again, this time removing all + // filters found in `redundantFilters`, and all permissions with empty + // filters (meaning all filters for that permissions are implied, so whole + // permission is in fact implied). + perms = perms.filter((partialPerm) => { + const currentPermUrl = partialPerm.url; + + // Remove given filter for given permission if it's on the list + partialPerm.filters = partialPerm.filters.filter( + (item) => + !redundantFilters.some((redundantFilter) => + isEqual(redundantFilter, {permUrl: currentPermUrl, filter: item}) + ) + ); + + // if filters array is empty, remove whole permission + return partialPerm.filters.length !== 0; + }); + + return perms; +} + +/** + * Removes implied permissions from the parsed list of BackendPerms. Also + * removes any implied partial permissions (technically removes implied filters) + * from within any single `partial_permissions` */ -function removeImpliedPerms(parsed: PermissionBase[]): PermissionBase[] { +export function removeImpliedPerms(parsed: PermissionBase[]): PermissionBase[] { + // Step 1. Loop through all given permissions and store all implied perms they + // have (as a flat list in `impliedPerms`). const impliedPerms = new Set(); parsed.forEach((backendPerm) => { const permDef = permConfig.getPermission(backendPerm.permission); @@ -141,10 +213,26 @@ function removeImpliedPerms(parsed: PermissionBase[]): PermissionBase[] { impliedPerms.add(impliedPerm); }); }); - parsed = parsed.filter( + + // Step 2. We remove implied from the outcome list + const output = parsed.filter( (backendPerm) => !impliedPerms.has(backendPerm.permission) ); - return parsed; + + // Step 3. Remove implied permissions for each `partial_submissions` left + output.forEach((backendPerm) => { + const permDef = permConfig.getPermission(backendPerm.permission); + if ( + permDef?.codename === 'partial_submissions' && + backendPerm.partial_permissions + ) { + backendPerm.partial_permissions = removeImpliedPartialPerms( + backendPerm.partial_permissions + ); + } + }); + + return output; } /** @@ -153,39 +241,76 @@ function removeImpliedPerms(parsed: PermissionBase[]): PermissionBase[] { */ export function parseFormData(data: PermsFormData): PermissionBase[] { let parsed = []; - // Gather all partial permissions first, and then build a partial_submissions - // grouped permission to add it to final data. + + // We need to gather all partial permissions first, because they end up as + // single `partial_submissions` permission with all partial permissions + // grouped inside of it. const partialPerms: PartialPermission[] = []; - [ - CHECKBOX_NAMES.formView, - CHECKBOX_NAMES.formEdit, - CHECKBOX_NAMES.formManage, - CHECKBOX_NAMES.submissionsAdd, - CHECKBOX_NAMES.submissionsView, - CHECKBOX_NAMES.submissionsEdit, - CHECKBOX_NAMES.submissionsValidate, - CHECKBOX_NAMES.submissionsDelete, - ].forEach((checkboxName) => { - const partialCheckboxName = getPartialByUsersCheckboxName(checkboxName); - - if (partialCheckboxName && data[partialCheckboxName]) { - const permCodename = PARTIAL_PERM_PAIRS[partialCheckboxName]; - - const listName = getPartialByUsersListName(partialCheckboxName); + // Step 1: Gather all partial "by users" permissions + for (const [checkboxName, permCodename] of Object.entries( + PARTIAL_BY_USERS_PERM_PAIRS + )) { + const byUsersCheckboxName = checkboxName as CheckboxNamePartialByUsers; + if (data[byUsersCheckboxName]) { + const listName = getPartialByUsersListName(byUsersCheckboxName); const partialUsers = data[listName] || []; + // For one user it will be string, for multiple it will be an `$in` object + const submittedByValue = + partialUsers.length === 1 ? partialUsers[0] : {$in: partialUsers}; + partialPerms.push({ url: getPermUrl(permCodename), - filters: [{_submitted_by: {$in: partialUsers}}], + filters: [{_submitted_by: submittedByValue}], }); - } else if (data[checkboxName]) { - parsed.push( - buildBackendPerm(data.username, CHECKBOX_PERM_PAIRS[checkboxName]) + } + } + + // Step 2: Gather all partial "by responses" permissions + for (const [checkboxName, permCodename] of Object.entries( + PARTIAL_BY_RESPONSES_PERM_PAIRS + )) { + const byResponsesCheckboxName = + checkboxName as CheckboxNamePartialByResponses; + if (data[byResponsesCheckboxName]) { + const questionProp = getPartialByResponsesQuestionName( + byResponsesCheckboxName ); + const question = data[questionProp]; + const valueProp = getPartialByResponsesValueName(byResponsesCheckboxName); + const value = data[valueProp]; // this can be empty string (and it's ok) + + if (question) { + const filter: PartialPermissionFilter = {[question]: value}; + const permUrl = getPermUrl(permCodename); + + // Step 2.1A: See if this permission is already in `partialPerms` - if + // such is the case, we will merge the filters, instead of creating + // separate permission + // NOTE: this is intentional (always producing AND instead of OR) and + // might be changed or extended in the future + const foundPerm = partialPerms.find( + (partialPerm) => partialPerm.url === permUrl + ); + // We are purposefully interested in first filter only, as UI is not + // able to handle OR filters (multiple filters) + const foundPermFilter = foundPerm?.filters[0]; + if (foundPermFilter) { + // We merge current filter into the existing one + foundPerm.filters[0] = {...foundPermFilter, ...filter}; + } else { + // Step 2.1B: If this is new permission, we simply add it + partialPerms.push({ + url: permUrl, + filters: [filter], + }); + } + } } - }); + } + // Step 3: Build final partial permission if (partialPerms.length >= 1) { const permObj = buildBackendPerm( data.username, @@ -195,6 +320,22 @@ export function parseFormData(data: PermsFormData): PermissionBase[] { parsed.push(permObj); } + // Step 4: Gather all non-partial permissions + for (const [checkboxNameString, permCodename] of Object.entries( + CHECKBOX_PERM_PAIRS + )) { + const checkboxName = checkboxNameString as CheckboxNameAll; + if ( + data[checkboxName] && + // Filter out partial checkboxes + checkboxName in PARTIAL_BY_USERS_PERM_PAIRS === false && + checkboxName in PARTIAL_BY_RESPONSES_PERM_PAIRS === false + ) { + parsed.push(buildBackendPerm(data.username, permCodename)); + } + } + + // Step 5. Remove contradictory and implied permissions parsed = removeContradictoryPerms(parsed); parsed = removeImpliedPerms(parsed); @@ -202,17 +343,25 @@ export function parseFormData(data: PermsFormData): PermissionBase[] { } /** - * Builds form data from list of permissions. + * Builds form data from a list of permissions. It will only produce data for + * the properties that comes directly from these permissions, i.e. there will + * be nothing in returned object that comes from implied permissions. Other + * functions are ensuring that (see `applyValidityRules` function from + * `userAssetPermsEditor.utils.ts` file). */ export function buildFormData( - permissions: UserPerm[], + permissions: PermissionResponse[], username?: string ): PermsFormData { const formData: PermsFormData = { username: username || '', }; - permissions.forEach((perm) => { + // The UI code is confused when it gets all implied permissions together with + // the "actual" ones, so we need to do some cleanup first + const deimpliedPerms = removeImpliedPerms(permissions); + + deimpliedPerms.forEach((perm) => { if (perm.permission === getPermUrl('view_asset')) { formData.formView = true; } @@ -224,31 +373,80 @@ export function buildFormData( } if (perm.permission === getPermUrl('partial_submissions')) { perm.partial_permissions?.forEach((partial) => { + // Step 1. For each partial permission we start off getting the nested + // definition, so we can get the codename from it const permDef = permConfig.getPermission(partial.url); if (!permDef) { return; } + + // Step 2. Using the codename, we get the matching non-partial checkbox + // name - we will need it later const nonPartialCheckboxName = getCheckboxNameByPermission( permDef.codename ); if (!nonPartialCheckboxName) { return; } - const partialCheckboxName = getPartialByUsersCheckboxName( - nonPartialCheckboxName - ); - if (!partialCheckboxName) { - return; + + // Step 3. We assume here that there might be a case of 1 or 2 filters + // tops. There might be one "by users" or one "by responses" or one each + // - no other possiblities can happen. We get each of them separately + // and try to put them back as form data: + const byUsersFilterList = getPartialByUsersFilterList(partial); + const byResponsesFilter = getPartialByResponsesFilter(partial); + + // Step 4. Handle "by users" filter (if one exists for this permission) + if (byUsersFilterList) { + const byUsersCheckboxName = getPartialByUsersCheckboxName( + nonPartialCheckboxName + ); + if (byUsersCheckboxName) { + const byUsersListName = + getPartialByUsersListName(byUsersCheckboxName); + + // Step 4A. Set the list of usernames + const filterUsernames = byUsersFilterList; + formData[byUsersListName] = filterUsernames; + + // Step 4B. Enable "by users" checkbox (but only if the users list + // is not empty - in theory should not happen) + formData[byUsersCheckboxName] = filterUsernames.length > 0; + } } - formData[partialCheckboxName] = true; + // Step 5. Handle "by responses" filter (if one exists for this + // permission) + if (byResponsesFilter) { + const byResponsesCheckboxName = getPartialByResponsesCheckboxName( + nonPartialCheckboxName + ); + if (byResponsesCheckboxName) { + const byResponsesQuestionName = getPartialByResponsesQuestionName( + byResponsesCheckboxName + ); + const byResponsesValueName = getPartialByResponsesValueName( + byResponsesCheckboxName + ); - partial.filters.forEach((filter) => { - if (filter._submitted_by) { - const listName = getPartialByUsersListName(partialCheckboxName); - formData[listName] = filter._submitted_by.$in; + // Step 5A. Set question name + // Note that there is always one key with one value in this object, + // so that we can go with `[0]` without risk + formData[byResponsesQuestionName] = + Object.keys(byResponsesFilter)[0]; + const value = Object.values(byResponsesFilter)[0]; + if (typeof value === 'string') { + // Step 5B. Set value + formData[byResponsesValueName] = value; + } + + // Step 5C. Enable "by responses" checkbox (but only if question + // name is defined, value might be an empty string) + formData[byResponsesCheckboxName] = Boolean( + formData[byResponsesQuestionName] + ); } - }); + } }); } if (perm.permission === getPermUrl('add_submissions')) { @@ -299,7 +497,7 @@ export function parseUserWithPermsList( */ export function parseBackendData( /** Permissions array (results property from endpoint response) */ - data: PermissionResponse[], + perms: PermissionResponse[], /** Asset owner url (used as identifier) */ ownerUrl: string, /** Whether to include permissions assigned to the anonymous user */ @@ -307,22 +505,16 @@ export function parseBackendData( ): UserWithPerms[] { const output: UserWithPerms[] = []; - const groupedData: {[userName: string]: UserPerm[]} = {}; - data.forEach((item) => { + const groupedData: {[userName: string]: PermissionResponse[]} = {}; + perms.forEach((perm) => { // anonymous user permissions are our inner way of handling public sharing - if (getUsernameFromUrl(item.user) === ANON_USERNAME && !includeAnon) { + if (getUsernameFromUrl(perm.user) === ANON_USERNAME && !includeAnon) { return; } - if (!groupedData[item.user]) { - groupedData[item.user] = []; + if (!groupedData[perm.user]) { + groupedData[perm.user] = []; } - groupedData[item.user].push({ - url: item.url, - permission: item.permission, - partial_permissions: item.partial_permissions - ? item.partial_permissions - : undefined, - }); + groupedData[perm.user].push(perm); }); Object.keys(groupedData).forEach((userUrl) => { diff --git a/jsapp/js/components/permissions/sharingForm.component.tsx b/jsapp/js/components/permissions/sharingForm.component.tsx index 410de30281..91361e52f5 100644 --- a/jsapp/js/components/permissions/sharingForm.component.tsx +++ b/jsapp/js/components/permissions/sharingForm.component.tsx @@ -201,7 +201,7 @@ export default class SharingForm extends React.Component< )} {/* list of users and their permissions */} - +

{t('Who has access')}

{this.state.permissions.map((perm) => { diff --git a/jsapp/js/components/permissions/sharingForm.scss b/jsapp/js/components/permissions/sharingForm.scss index 40dac3e813..a6069df0f2 100644 --- a/jsapp/js/components/permissions/sharingForm.scss +++ b/jsapp/js/components/permissions/sharingForm.scss @@ -125,13 +125,16 @@ $s-gray-row-spacing: 10px; } } -// ----------------------------------------------------------------------------- -// UserAssetPermsEditor -// ----------------------------------------------------------------------------- +// We want to avoud UI jumping when question with a long name is being selected +// in "based on a condition" dropdown +.modal .form-modal__item--who-has-access { + min-width: 600px; +} .form-modal__item--copy-team-permissions { position: relative; + // The closing button is not a part of UserAssetPermsEditor .user-permissions-editor-closer { position: absolute; top: 5px; @@ -143,44 +146,6 @@ $s-gray-row-spacing: 10px; font-weight: bold; } -.user-permissions-editor { - .react-tagsinput-input { - background-color: colors.$kobo-white; - } - - .user-permissions-editor__row > *:not(:last-child), - .user-permissions-editor__sub-row > *:not(:last-child) { - margin-bottom: 5px; - } - - .user-permissions-editor__row { - &:not(:last-child) { - margin-bottom: 10px; - } - - &.user-permissions-editor__row--username { - width: calc(100% - 32px); - } - } - - .user-permissions-editor__sub-row { - padding-left: 30px; - position: relative; - - &::before { - content: ''; - position: absolute; - top: 0; - left: 10px; - width: 15px; - height: 15px; - border-left: 1px solid colors.$kobo-gray-85; - border-bottom: 1px solid colors.$kobo-gray-85; - border-radius: 2px; - } - } -} - // ----------------------------------------------------------------------------- // common parts // ----------------------------------------------------------------------------- diff --git a/jsapp/js/components/permissions/userAssetPermsEditor.component.tsx b/jsapp/js/components/permissions/userAssetPermsEditor.component.tsx index c9ca3d34f4..ea2635e28a 100644 --- a/jsapp/js/components/permissions/userAssetPermsEditor.component.tsx +++ b/jsapp/js/components/permissions/userAssetPermsEditor.component.tsx @@ -1,46 +1,59 @@ import React from 'react'; import clonedeep from 'lodash.clonedeep'; +import cx from 'classnames'; import Checkbox from 'js/components/common/checkbox'; import TextBox from 'js/components/common/textBox'; import Button from 'js/components/common/button'; -import sessionStore from 'js/stores/session'; +import AriaText from 'js/components/common/ariaText'; import {actions} from 'js/actions'; import bem from 'js/bem'; import {parseFormData, buildFormData} from './permParser'; -import type {UserPerm, PermsFormData} from './permParser'; -import permConfig from './permConfig'; import {notify} from 'js/utils'; import {buildUserUrl, ANON_USERNAME} from 'js/users/utils'; import {KEY_CODES} from 'js/constants'; import { - PARTIAL_PERM_PAIRS, + CHECKBOX_DISABLED_SUFFIX, CHECKBOX_NAMES, CHECKBOX_PERM_PAIRS, - PARTIAL_IMPLIED_CHECKBOX_PAIRS, CHECKBOX_LABELS, } from './permConstants'; import type { CheckboxNameAll, CheckboxNamePartialByUsers, + CheckboxNamePartialByResponses, PartialByUsersListName, PermissionCodename, } from './permConstants'; import type {AssignablePermsMap} from './sharingForm.component'; -import type {PermissionBase} from 'js/dataInterface'; +import type {PermissionBase, PermissionResponse} from 'js/dataInterface'; import userExistence from 'js/users/userExistence.store'; -import {getPartialByUsersListName} from './utils'; +import { + getPartialByUsersListName, + getPartialByResponsesQuestionName, + getPartialByResponsesValueName, +} from './utils'; +import {getSurveyFlatPaths} from 'js/assetUtils'; +import assetStore from 'js/assetStore'; +import KoboSelect from 'js/components/common/koboSelect'; +import type {KoboSelectOption} from 'js/components/common/koboSelect'; +import { + applyValidityRules, + isAssignable, + isPartialByUsersValid, + isPartialByResponsesValid, + getFormData, +} from './userAssetPermsEditor.utils'; +import styles from './userAssetPermsEditor.module.scss'; const PARTIAL_PLACEHOLDER = t('Enter usernames separated by comma'); const USERNAMES_SEPARATOR = ','; -const SUFFIX_DISABLED = 'Disabled'; - interface UserAssetPermsEditorProps { assetUid: string; /** Permissions user username (could be empty for new) */ username?: string; /** list of permissions (could be empty for new) */ - permissions?: UserPerm[]; + permissions?: PermissionResponse[]; assignablePerms: AssignablePermsMap; /** List of permissions with exclusion of the asset owner permissions */ nonOwnerPerms: PermissionBase[]; @@ -48,7 +61,8 @@ interface UserAssetPermsEditorProps { onSubmitEnd: (isSuccess: boolean) => void; } -interface UserAssetPermsEditorState { +/** Note that this bares a lot of similarities with `PermsFormData` interface */ +export interface UserAssetPermsEditorState { isSubmitPending: boolean; // We need both `isEditingUsername` and `isCheckingUsername` to block sending // permissions to Back end, when we're not sure if user exists. @@ -65,21 +79,41 @@ interface UserAssetPermsEditorState { submissionsView: boolean; submissionsViewDisabled: boolean; submissionsViewPartialByUsers: boolean; + submissionsViewPartialByUsersDisabled: boolean; submissionsViewPartialByUsersList: string[]; + submissionsViewPartialByResponses: boolean; + submissionsViewPartialByResponsesDisabled: boolean; + submissionsViewPartialByResponsesQuestion: string | null; + submissionsViewPartialByResponsesValue: string; submissionsAdd: boolean; submissionsAddDisabled: boolean; submissionsEdit: boolean; submissionsEditDisabled: boolean; submissionsEditPartialByUsers: boolean; + submissionsEditPartialByUsersDisabled: boolean; submissionsEditPartialByUsersList: string[]; + submissionsEditPartialByResponses: boolean; + submissionsEditPartialByResponsesDisabled: boolean; + submissionsEditPartialByResponsesQuestion: string | null; + submissionsEditPartialByResponsesValue: string; submissionsValidate: boolean; submissionsValidateDisabled: boolean; submissionsValidatePartialByUsers: boolean; + submissionsValidatePartialByUsersDisabled: boolean; submissionsValidatePartialByUsersList: string[]; + submissionsValidatePartialByResponses: boolean; + submissionsValidatePartialByResponsesDisabled: boolean; + submissionsValidatePartialByResponsesQuestion: string | null; + submissionsValidatePartialByResponsesValue: string; submissionsDelete: boolean; submissionsDeleteDisabled: boolean; submissionsDeletePartialByUsers: boolean; + submissionsDeletePartialByUsersDisabled: boolean; submissionsDeletePartialByUsersList: string[]; + submissionsDeletePartialByResponses: boolean; + submissionsDeletePartialByResponsesDisabled: boolean; + submissionsDeletePartialByResponsesQuestion: string | null; + submissionsDeletePartialByResponsesValue: string; } /** @@ -108,21 +142,41 @@ export default class UserAssetPermsEditor extends React.Component< submissionsView: false, submissionsViewDisabled: false, submissionsViewPartialByUsers: false, + submissionsViewPartialByUsersDisabled: false, submissionsViewPartialByUsersList: [], + submissionsViewPartialByResponses: false, + submissionsViewPartialByResponsesDisabled: false, + submissionsViewPartialByResponsesQuestion: null, + submissionsViewPartialByResponsesValue: '', submissionsAdd: false, submissionsAddDisabled: false, submissionsEdit: false, submissionsEditDisabled: false, submissionsEditPartialByUsers: false, + submissionsEditPartialByUsersDisabled: false, submissionsEditPartialByUsersList: [], + submissionsEditPartialByResponses: false, + submissionsEditPartialByResponsesDisabled: false, + submissionsEditPartialByResponsesQuestion: null, + submissionsEditPartialByResponsesValue: '', submissionsValidate: false, submissionsValidateDisabled: false, submissionsValidatePartialByUsers: false, + submissionsValidatePartialByUsersDisabled: false, submissionsValidatePartialByUsersList: [], + submissionsValidatePartialByResponses: false, + submissionsValidatePartialByResponsesDisabled: false, + submissionsValidatePartialByResponsesQuestion: null, + submissionsValidatePartialByResponsesValue: '', submissionsDelete: false, submissionsDeleteDisabled: false, submissionsDeletePartialByUsers: false, + submissionsDeletePartialByUsersDisabled: false, submissionsDeletePartialByUsersList: [], + submissionsDeletePartialByResponses: false, + submissionsDeletePartialByResponsesDisabled: false, + submissionsDeletePartialByResponsesQuestion: null, + submissionsDeletePartialByResponsesValue: '', }; this.applyPropsData(); @@ -137,26 +191,42 @@ export default class UserAssetPermsEditor extends React.Component< */ checkedUsernames: Map = new Map(); + private unlisteners: Function[] = []; + /** * Fills up form with provided user name and permissions (if applicable) */ applyPropsData() { + // Build form data from given existing permissions (e.g. when this component + // is being used to edit existing permissions) const formData = buildFormData( this.props.permissions || [], this.props.username ); - this.state = this.applyValidityRules(Object.assign(this.state, formData)); + + // Merge built form data with existing state (with defaults) and then apply + // validity rules (handles disabling and checking/unchecking properties + // based on implied/contradictory rules from `permConfig`). + this.state = applyValidityRules(Object.assign(this.state, formData)); } componentDidMount() { - actions.permissions.bulkSetAssetPermissions.completed.listen( - this.onBulkSetAssetPermissionCompleted.bind(this) - ); - actions.permissions.bulkSetAssetPermissions.failed.listen( - this.onBulkSetAssetPermissionFailed.bind(this) + this.unlisteners.push( + actions.permissions.bulkSetAssetPermissions.completed.listen( + this.onBulkSetAssetPermissionCompleted.bind(this) + ), + actions.permissions.bulkSetAssetPermissions.failed.listen( + this.onBulkSetAssetPermissionFailed.bind(this) + ) ); } + componentWillUnmount() { + this.unlisteners.forEach((clb) => { + clb(); + }); + } + onBulkSetAssetPermissionCompleted() { this.setState({isSubmitPending: false}); this.notifyParentAboutSubmitEnd(true); @@ -176,119 +246,6 @@ export default class UserAssetPermsEditor extends React.Component< } } - /** - * Helps to avoid users submitting invalid data. - * - * Checking some of the checkboxes implies that other are also checked - * and can't be unchecked. - * - * Checking some of the checkboxes implies that other can't be checked. - * - * Returns updated state object - */ - applyValidityRules(stateObj: UserAssetPermsEditorState) { - // Step 1: Avoid mutation - let output = clonedeep(stateObj); - - // Step 2: Enable all checkboxes (make them not disabled) before applying - // the rules - for (const [, checkboxName] of Object.entries(CHECKBOX_NAMES)) { - output = Object.assign(output, {[checkboxName + SUFFIX_DISABLED]: false}); - } - - // Step 3: Lock submission add -- OUTDATED after per project anonymous submissions - - // Step 4: Apply permissions configuration rules to checkboxes - for (const [, checkboxName] of Object.entries(CHECKBOX_NAMES)) { - output = this.applyValidityRulesForCheckbox(checkboxName, output); - } - - // Step 5: For each unchecked partial checkbox, clean up the list of users - for (const [, checkboxName] of Object.entries(CHECKBOX_NAMES)) { - if ( - checkboxName in PARTIAL_PERM_PAIRS && - output[checkboxName] === false - ) { - // We cast it here, because it is definitely a partial checkbox - const listName = getPartialByUsersListName( - checkboxName as CheckboxNamePartialByUsers - ); - output = Object.assign(output, {[listName]: []}); - } - } - - return output; - } - - /** - * For given checkbox (permission) uses permissions config to fix all implied - * and contradictory checkboxes (permissions). - * - * Returns updated state object - */ - applyValidityRulesForCheckbox( - checkboxName: CheckboxNameAll, - stateObj: UserAssetPermsEditorState - ) { - let output = clonedeep(stateObj); - - // Step 1: Only apply the rules for checked checkboxes - if (output[checkboxName] === false) { - return output; - } - - // Step 2: Get implied and contradictory perms from definition - const permDef = permConfig.getPermissionByCodename( - CHECKBOX_PERM_PAIRS[checkboxName] - ); - const impliedPerms = permDef?.implied || []; - const contradictoryPerms = permDef?.contradictory || []; - - // Step 3: All implied will be checked and disabled - impliedPerms.forEach((permUrl) => { - const impliedPermDef = permConfig.getPermission(permUrl); - if (!impliedPermDef) { - return; - } - - let impliedCheckboxes = this.getPermissionCheckboxPairs( - impliedPermDef.codename - ); - if (checkboxName in PARTIAL_IMPLIED_CHECKBOX_PAIRS) { - impliedCheckboxes = impliedCheckboxes.concat( - PARTIAL_IMPLIED_CHECKBOX_PAIRS[checkboxName] - ); - } - - impliedCheckboxes.forEach((impliedCheckbox) => { - output = Object.assign(output, { - [impliedCheckbox]: true, - [impliedCheckbox + SUFFIX_DISABLED]: true, - }); - }); - }); - - // Step 4: All contradictory will be unchecked and disabled - contradictoryPerms.forEach((permUrl) => { - const contradictoryPermDef = permConfig.getPermission(permUrl); - if (!contradictoryPermDef) { - return; - } - - const contradictoryCheckboxes = this.getPermissionCheckboxPairs( - contradictoryPermDef.codename - ); - contradictoryCheckboxes.forEach((contradictoryCheckbox) => { - output = Object.assign(output, { - [contradictoryCheckbox]: false, - [contradictoryCheckbox + SUFFIX_DISABLED]: true, - }); - }); - }); - - return output; - } - /** * Single callback for all checkboxes to keep the complex connections logic * being up to date regardless which one changed. @@ -296,7 +253,7 @@ export default class UserAssetPermsEditor extends React.Component< onCheckboxChange(checkboxName: CheckboxNameAll, isChecked: boolean) { let output = clonedeep(this.state); output = Object.assign(output, {[checkboxName]: isChecked}); - this.setState(this.applyValidityRules(output)); + this.setState(applyValidityRules(output)); } /** @@ -362,6 +319,10 @@ export default class UserAssetPermsEditor extends React.Component< notify(`${t('User not found:')} ${username}`, 'warning'); } + /** + * A generic callback for text inputs that blur out of the element when ENTER + * key is pressed - ensuring that the form is not submitted. + */ onInputKeyPress(key: string, evt: React.KeyboardEvent) { if (key === String(KEY_CODES.ENTER)) { evt.currentTarget.blur(); @@ -380,31 +341,9 @@ export default class UserAssetPermsEditor extends React.Component< this.setState(output); } - /** - * Multiple checkboxes have `partial_submissions`, so this function returns - * an array of items - */ - getPermissionCheckboxPairs(permCodename: PermissionCodename) { - const found: CheckboxNameAll[] = []; - - for (const [checkboxName, checkboxPermPair] of Object.entries( - CHECKBOX_PERM_PAIRS - )) { - if (checkboxPermPair === permCodename) { - found.push(checkboxName as CheckboxNameAll); - } - } - - return found; - } - + // We make a proxy here to avoid passing `assignablePerms` props each time isAssignable(permCodename: PermissionCodename) { - const permDef = permConfig.getPermissionByCodename(permCodename); - if (!permDef) { - return false; - } else { - return this.props.assignablePerms.has(permDef.url); - } + return isAssignable(permCodename, this.props.assignablePerms); } /** @@ -420,10 +359,26 @@ export default class UserAssetPermsEditor extends React.Component< return ( isAnyCheckboxChecked && - this.isPartialByUsersValid('submissionsViewPartialByUsers') && - this.isPartialByUsersValid('submissionsEditPartialByUsers') && - this.isPartialByUsersValid('submissionsDeletePartialByUsers') && - this.isPartialByUsersValid('submissionsValidatePartialByUsers') && + isPartialByUsersValid('submissionsViewPartialByUsers', this.state) && + isPartialByUsersValid('submissionsEditPartialByUsers', this.state) && + isPartialByUsersValid('submissionsDeletePartialByUsers', this.state) && + isPartialByUsersValid('submissionsValidatePartialByUsers', this.state) && + isPartialByResponsesValid( + 'submissionsViewPartialByResponses', + this.state + ) && + isPartialByResponsesValid( + 'submissionsEditPartialByResponses', + this.state + ) && + isPartialByResponsesValid( + 'submissionsDeletePartialByResponses', + this.state + ) && + isPartialByResponsesValid( + 'submissionsValidatePartialByResponses', + this.state + ) && !this.state.isSubmitPending && !this.state.isEditingUsername && !this.state.isCheckingUsername && @@ -433,44 +388,6 @@ export default class UserAssetPermsEditor extends React.Component< ); } - /** - * The list of users for …PartialByUsers checkbox can't be empty if - * the checkbox is checked - */ - isPartialByUsersValid(partialCheckboxName: CheckboxNamePartialByUsers) { - // If partial checkbox is checked, we require the users list to not be empty - if (this.state[partialCheckboxName] === true) { - return ( - this.state[getPartialByUsersListName(partialCheckboxName)].length !== 0 - ); - } - return true; - } - - /** - * Returns only the properties for assignable permissions - */ - getFormData() { - const output: PermsFormData = { - username: this.state.username, - }; - - for (const [, checkboxName] of Object.entries(CHECKBOX_NAMES)) { - if (this.isAssignable(CHECKBOX_PERM_PAIRS[checkboxName])) { - output[checkboxName] = this.state[checkboxName]; - if (checkboxName in PARTIAL_PERM_PAIRS) { - // We cast it here, because it is definitely a partial checkbox - const listName = getPartialByUsersListName( - checkboxName as CheckboxNamePartialByUsers - ); - output[listName] = this.state[listName]; - } - } - } - - return output; - } - onSubmit(evt: React.FormEvent) { evt.preventDefault(); @@ -478,7 +395,7 @@ export default class UserAssetPermsEditor extends React.Component< return; } - const formData = this.getFormData(); + const formData = getFormData(this.state, this.props.assignablePerms); const parsedPerms = parseFormData(formData); @@ -507,7 +424,7 @@ export default class UserAssetPermsEditor extends React.Component< // We need to trick TypeScript here, because we don't want to refactor too // much code to make it perfect const disabledPropName = (checkboxName + - SUFFIX_DISABLED) as keyof UserAssetPermsEditorState; + CHECKBOX_DISABLED_SUFFIX) as keyof UserAssetPermsEditorState; const isDisabled = Boolean(this.state[disabledPropName]); return ( - ); + renderPartialByUsersRow(checkboxName: CheckboxNamePartialByUsers) { + if (this.isAssignable(CHECKBOX_PERM_PAIRS[checkboxName])) { + const listName = getPartialByUsersListName(checkboxName); + return ( +
+ {this.renderCheckbox(checkboxName)} + + {this.state[checkboxName] === true && ( + + )} +
+ ); + } else { + return null; + } } - renderPartialRow(checkboxName: CheckboxNamePartialByUsers) { + getQuestionNameSelectOptions(): KoboSelectOption[] { + const output: KoboSelectOption[] = []; + const foundAsset = assetStore.getAsset(this.props.assetUid); + if (foundAsset?.content?.survey) { + const flatPaths = getSurveyFlatPaths( + foundAsset.content?.survey, + false, + true + ); + for (const [, qPath] of Object.entries(flatPaths)) { + output.push({ + value: qPath, + label: qPath, + }); + } + } + return output; + } + + /** + * Displays a checkbox for enabling partial "by responses" permission editor + * that includes a question (name) selector and a text input for typing + * the value to filter by. + */ + renderPartialByResponsesRow(checkboxName: CheckboxNamePartialByResponses) { if (this.isAssignable(CHECKBOX_PERM_PAIRS[checkboxName])) { + const questionProp = getPartialByResponsesQuestionName(checkboxName); + const valueProp = getPartialByResponsesValueName(checkboxName); + return ( -
+
{this.renderCheckbox(checkboxName)} - {this.state[checkboxName] === true && - this.renderUsersTextbox(checkboxName)} + {this.state[checkboxName] === true && ( +
+ + { + // Update state object in non mutable way + let output = clonedeep(this.state); + output = Object.assign(output, { + [questionProp]: newSelectedOption, + }); + this.setState(output); + }} + /> + + + {/* We display an equals character between elements here :) */} + + + + { + // Update state object in non mutable way + let output = clonedeep(this.state); + output = Object.assign(output, { + [valueProp]: newVal, + }); + this.setState(output); + }} + /> + + +
+ )}
); } else { @@ -568,7 +582,7 @@ export default class UserAssetPermsEditor extends React.Component< > {isNew && ( // don't display username editor when editing existing user -
+
)} -
+
{this.isAssignable('view_asset') && this.renderCheckbox('formView')} {this.isAssignable('change_asset') && this.renderCheckbox('formEdit')} {this.isAssignable('view_submissions') && this.renderCheckbox('submissionsView')} - {this.renderPartialRow('submissionsViewPartialByUsers')} + {this.renderPartialByUsersRow('submissionsViewPartialByUsers')} + {this.renderPartialByResponsesRow( + 'submissionsViewPartialByResponses' + )} {this.isAssignable('add_submissions') && this.renderCheckbox('submissionsAdd')} {this.isAssignable('change_submissions') && this.renderCheckbox('submissionsEdit')} - {this.renderPartialRow('submissionsEditPartialByUsers')} + {this.renderPartialByUsersRow('submissionsEditPartialByUsers')} + {this.renderPartialByResponsesRow( + 'submissionsEditPartialByResponses' + )} {this.isAssignable('validate_submissions') && this.renderCheckbox('submissionsValidate')} - {this.renderPartialRow('submissionsValidatePartialByUsers')} + {this.renderPartialByUsersRow('submissionsValidatePartialByUsers')} + {this.renderPartialByResponsesRow( + 'submissionsValidatePartialByResponses' + )} {this.isAssignable('delete_submissions') && this.renderCheckbox('submissionsDelete')} - {this.renderPartialRow('submissionsDeletePartialByUsers')} + {this.renderPartialByUsersRow('submissionsDeletePartialByUsers')} + {this.renderPartialByResponsesRow( + 'submissionsDeletePartialByResponses' + )} {this.isAssignable('manage_asset') && this.renderCheckbox('formManage')}
-
+