├── .eslintrc.json ├── .github └── workflows │ └── publish-on-marketplace-ci.yml ├── .gitignore ├── CHANGELOG.md ├── EditableTable ├── ControlManifest.Input.xml ├── components │ ├── AppWrapper.tsx │ ├── EditableGrid │ │ ├── CommandBar.tsx │ │ ├── EditableGrid.tsx │ │ ├── GridCell.tsx │ │ └── GridFooter.tsx │ ├── ErrorIcon.tsx │ ├── InputComponents │ │ ├── DateTimeFormat.tsx │ │ ├── LookupFormat.tsx │ │ ├── NumberFormat.tsx │ │ ├── OptionSetFormat.tsx │ │ ├── TextFormat.tsx │ │ ├── WholeFormat.tsx │ │ ├── durationList.ts │ │ └── timeList.ts │ └── Loading.tsx ├── hooks │ ├── useLoadStore.ts │ ├── usePagination.ts │ └── useSelection.ts ├── index.ts ├── mappers │ └── dataSetMapper.tsx ├── services │ └── DataverseService.ts ├── store │ ├── features │ │ ├── DatasetSlice.ts │ │ ├── DateSlice.ts │ │ ├── DropdownSlice.ts │ │ ├── ErrorSlice.ts │ │ ├── LoadingSlice.ts │ │ ├── LookupSlice.ts │ │ ├── NumberSlice.ts │ │ ├── RecordSlice.ts │ │ ├── TextSlice.ts │ │ └── WholeFormatSlice.ts │ ├── hooks.ts │ └── store.ts ├── styles │ ├── ButtonStyles.ts │ ├── ComponentsStyles.ts │ ├── DatasetStyles.css │ ├── DetailsListStyles.ts │ ├── FooterStyles.ts │ └── RenderStyles.tsx └── utils │ ├── commonUtils.ts │ ├── dateTimeUtils.ts │ ├── durationUtils.ts │ ├── errorUtils.ts │ ├── fetchUtils.ts │ ├── formattingUtils.ts │ ├── textUtils.ts │ └── types.ts ├── LICENSE ├── MarketplaceAssets └── images │ ├── primary.png │ └── thumbnail.png ├── PCF-EditableTable.pcfproj ├── README.md ├── Solution ├── Solution.cdsproj └── src │ └── Other │ ├── Customizations.xml │ ├── Relationships.xml │ └── Solution.xml ├── package-lock.json ├── package.json ├── pcfconfig.json └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "browser": true, 5 | "es2021": true 6 | }, 7 | "extends": [ 8 | "bever", 9 | "eslint:recommended", 10 | "plugin:react/recommended", 11 | "plugin:@typescript-eslint/eslint-recommended", 12 | "plugin:@typescript-eslint/recommended" 13 | ], 14 | "globals": { 15 | "ComponentFramework": true 16 | }, 17 | "parser": "@typescript-eslint/parser", 18 | "parserOptions": { 19 | "ecmaVersion": 12, 20 | "sourceType": "module" 21 | }, 22 | "plugins": [ 23 | "@microsoft/power-apps", 24 | "@typescript-eslint" 25 | ], 26 | "rules": { 27 | "no-unused-vars": "off", 28 | "no-use-before-define": "off", 29 | "default-param-last":"off", 30 | "class-methods-use-this": "off", 31 | "@typescript-eslint/ban-types": "off", 32 | "@typescript-eslint/no-empty-function": "off", 33 | "@typescript-eslint/no-empty-interface": "off", 34 | "@typescript-eslint/no-use-before-define": ["error"], 35 | "lines-between-class-members": [ 36 | "error", 37 | "always", 38 | { 39 | "exceptAfterSingleLine": true 40 | } 41 | ], 42 | "max-len": [ 43 | "error", 44 | { 45 | "code": 100, 46 | "ignoreUrls": true, 47 | "ignoreComments": true 48 | } 49 | ] 50 | }, 51 | "settings": { 52 | "react": { 53 | "version": "detect" 54 | }, 55 | "import/resolver": { 56 | "node": { 57 | "extensions": [".tsx", ".d.ts", ".ts"] 58 | } 59 | } 60 | } 61 | } -------------------------------------------------------------------------------- /.github/workflows/publish-on-marketplace-ci.yml: -------------------------------------------------------------------------------- 1 | name: Publish on Bever Marketplace CI 2 | 3 | on: 4 | push: 5 | branches: release 6 | 7 | jobs: 8 | main: 9 | uses: BeverCRM/Workflow-Build-Release-Upload-Update/.github/workflows/build-release-upload-update-rw.yml@master 10 | secrets: inherit 11 | with: 12 | control-title: Editable Table 13 | control-youtube-video-url: https://www.youtube.com/watch?v=O8-jBsG4XfE 14 | control-tags: Dataset, Grid, Table, Editable 15 | create-new-release: false 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # generated directory 7 | **/generated 8 | 9 | # output directory 10 | /out 11 | 12 | # msbuild output directories 13 | /bin 14 | /obj 15 | 16 | # Visual Studio 17 | .vs 18 | .vscode 19 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [v1.1.0]( https://github.com/BeverCRM/PCF-EditableTable/releases/tag/v1.1.0) (2023-10-02) 2 | 3 | ### Bugs 4 | * Fixed an issue where the time wasn't set correctly. 5 | * Fixed an issue where deleted rows remain in the table in case of a slow internet connection. 6 | 7 | ### New Features 8 | 9 | * Added sort by column functionality. 10 | * Added position sticky header & footer styles. 11 | * Added navigation between columns by left-arrow and right-arrow keys. 12 | * Added support for read-only calculated fields. 13 | * Added support for read-only fields from the parent entity in the view. 14 | * Added error message consolidation in one popup. 15 | * Added consideration of system-level settings for date picker calendar. 16 | * Added security roles consideration to show hide save or delete buttons. 17 | * Added off-line mode support with disabled lookup fields. 18 | 19 | ### Improvements 20 | * General UI improvements. 21 | 22 | ## [v1.0.0]( https://github.com/BeverCRM/PCF-EditableTable/releases/tag/v1.0.0) (2023-05-08) 23 | 24 | Editable Grid with create, delete, save and refresh functionalities. New records are created as a new line in the grid. 25 | -------------------------------------------------------------------------------- /EditableTable/ControlManifest.Input.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 14 | 15 | 19 | 20 | 21 | 22 | 23 | 24 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 38 | 39 | 40 | 48 | 49 | 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /EditableTable/components/AppWrapper.tsx: -------------------------------------------------------------------------------- 1 | import { ScrollablePane, Stack } from '@fluentui/react'; 2 | import React, { useCallback, useEffect, useState } from 'react'; 3 | import { Provider } from 'react-redux'; 4 | import { IDataverseService } from '../services/DataverseService'; 5 | import { Store } from '../utils/types'; 6 | import { EditableGrid } from './EditableGrid/EditableGrid'; 7 | import { Loading } from './Loading'; 8 | import { getContainerHeight } from '../utils/commonUtils'; 9 | 10 | type DataSet = ComponentFramework.PropertyTypes.DataSet; 11 | 12 | export interface IDataSetProps { 13 | dataset: DataSet; 14 | isControlDisabled: boolean; 15 | width: number; 16 | _store: Store; 17 | _service: IDataverseService; 18 | _setContainerHeight: Function; 19 | } 20 | 21 | export const Wrapper = (props: IDataSetProps) => { 22 | const [containerHeight, setContainerHeight] = 23 | useState(getContainerHeight(props.dataset.sortedRecordIds.length)); 24 | 25 | const _setContainerHeight = useCallback((height: number) => { 26 | setContainerHeight(height); 27 | }, []); 28 | 29 | return 30 |
31 | 32 | 33 | 34 | 35 | 36 | 37 |
38 |
; 39 | }; 40 | -------------------------------------------------------------------------------- /EditableTable/components/EditableGrid/CommandBar.tsx: -------------------------------------------------------------------------------- 1 | import { CommandBarButton, IButtonStyles } from '@fluentui/react'; 2 | import * as React from 'react'; 3 | import { useAppSelector } from '../../store/hooks'; 4 | import { commandBarButtonStyles } from '../../styles/ButtonStyles'; 5 | import { addIcon, refreshIcon, deleteIcon, saveIcon } from '../../styles/ButtonStyles'; 6 | import { IIconProps } from '@fluentui/react/lib/components/Icon/Icon.types'; 7 | 8 | export interface ICommandBarProps { 9 | refreshButtonHandler: () => void; 10 | newButtonHandler: () => void; 11 | deleteButtonHandler: () => void; 12 | saveButtonHandler: () => void; 13 | isControlDisabled: boolean; 14 | selectedCount: number; 15 | } 16 | 17 | type ButtonProps = { 18 | order: number, 19 | text: string, 20 | icon: IIconProps, 21 | disabled: boolean, 22 | onClick: () => void, 23 | styles?: IButtonStyles, 24 | } 25 | 26 | export const CommandBar = (props: ICommandBarProps) => { 27 | const isLoading = useAppSelector(state => state.loading.isLoading); 28 | const isPendingSave = useAppSelector(state => state.record.isPendingSave); 29 | const entityPrivileges = useAppSelector(state => state.dataset.entityPrivileges); 30 | 31 | const buttons: ButtonProps[] = [ 32 | { 33 | order: 1, 34 | text: 'New', 35 | icon: addIcon, 36 | disabled: isLoading || props.isControlDisabled || !entityPrivileges.create, 37 | onClick: props.newButtonHandler, 38 | }, 39 | { 40 | order: 2, 41 | text: 'Refresh', 42 | icon: refreshIcon, 43 | disabled: isLoading, 44 | onClick: props.refreshButtonHandler, 45 | }, 46 | { 47 | order: 3, 48 | text: 'Delete', 49 | icon: deleteIcon, 50 | disabled: isLoading || props.isControlDisabled || !entityPrivileges.delete, 51 | onClick: props.deleteButtonHandler, 52 | styles: { 53 | root: { display: props.selectedCount > 0 ? 'flex' : 'none' }, 54 | icon: { color: 'black' }, 55 | }, 56 | }, 57 | { 58 | order: 4, 59 | text: 'Save', 60 | icon: saveIcon, 61 | disabled: isLoading || !isPendingSave || props.isControlDisabled, 62 | onClick: props.saveButtonHandler, 63 | styles: { 64 | icon: { color: isPendingSave ? 'blue' : 'black' }, 65 | textContainer: { color: isPendingSave ? 'blue' : 'black' }, 66 | }, 67 | }, 68 | ]; 69 | 70 | const listButtons = buttons.map(button => 71 | ); 79 | 80 | return <> 81 | {listButtons} 82 | ; 83 | }; 84 | -------------------------------------------------------------------------------- /EditableTable/components/EditableGrid/EditableGrid.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { 3 | ColumnActionsMode, 4 | ConstrainMode, 5 | ContextualMenu, 6 | DetailsList, 7 | DetailsListLayoutMode, 8 | DirectionalHint, 9 | IColumn, 10 | IContextualMenuProps, 11 | IDetailsList, 12 | Stack, 13 | } from '@fluentui/react'; 14 | 15 | import { useSelection } from '../../hooks/useSelection'; 16 | import { useLoadStore } from '../../hooks/useLoadStore'; 17 | import { useAppDispatch, useAppSelector } from '../../store/hooks'; 18 | 19 | import { CommandBar } from './CommandBar'; 20 | import { GridFooter } from './GridFooter'; 21 | import { GridCell } from './GridCell'; 22 | 23 | import { 24 | clearChangedRecords, 25 | clearChangedRecordsAfterRefresh, 26 | deleteRecords, 27 | readdChangedRecordsAfterDelete, 28 | saveRecords, 29 | } from '../../store/features/RecordSlice'; 30 | import { setLoading } from '../../store/features/LoadingSlice'; 31 | import { 32 | addNewRow, 33 | readdNewRowsAfterDelete, 34 | removeNewRows, 35 | setRows, 36 | } from '../../store/features/DatasetSlice'; 37 | 38 | import { Row, Column, mapDataSetColumns, 39 | mapDataSetRows, getColumnsTotalWidth } from '../../mappers/dataSetMapper'; 40 | import { _onRenderDetailsHeader } from '../../styles/RenderStyles'; 41 | import { buttonStyles } from '../../styles/ButtonStyles'; 42 | import { gridStyles } from '../../styles/DetailsListStyles'; 43 | import { IDataSetProps } from '../AppWrapper'; 44 | import { getContainerHeight } from '../../utils/commonUtils'; 45 | import { clearInvalidFields } from '../../store/features/ErrorSlice'; 46 | 47 | const ASC_SORT = 0; 48 | const DESC_SORT = 1; 49 | 50 | export const EditableGrid = ({ _service, _setContainerHeight, 51 | dataset, isControlDisabled, width }: IDataSetProps) => { 52 | const { selection, selectedRecordIds } = useSelection(); 53 | 54 | const rows: Row[] = useAppSelector(state => state.dataset.rows); 55 | const newRows: Row[] = useAppSelector(state => state.dataset.newRows); 56 | const columns = mapDataSetColumns(dataset, _service); 57 | const isPendingDelete = useAppSelector(state => state.record.isPendingDelete); 58 | const isPendingLoad = useAppSelector(state => state.dataset.isPending); 59 | const [sortMenuProps, setSortMenuProps] = useState(undefined); 60 | 61 | const dispatch = useAppDispatch(); 62 | 63 | const detailsListRef = React.createRef(); 64 | 65 | const resetScroll = () => { 66 | detailsListRef.current?.scrollToIndex(0); 67 | }; 68 | 69 | const refreshButtonHandler = () => { 70 | dispatch(setLoading(true)); 71 | dataset.refresh(); 72 | dispatch(clearChangedRecords()); 73 | dispatch(clearChangedRecordsAfterRefresh()); 74 | dispatch(removeNewRows()); 75 | dispatch(clearInvalidFields()); 76 | }; 77 | 78 | const newButtonHandler = () => { 79 | resetScroll(); 80 | const emptyColumns = columns.map(column => ({ 81 | schemaName: column.key, 82 | rawValue: '', 83 | formattedValue: '', 84 | type: column.data, 85 | })); 86 | 87 | dispatch(addNewRow({ 88 | key: Date.now().toString(), 89 | columns: emptyColumns, 90 | })); 91 | _setContainerHeight(getContainerHeight(rows.length + 1)); 92 | }; 93 | 94 | const deleteButtonHandler = () => { 95 | dispatch(setLoading(true)); 96 | dispatch(deleteRecords({ recordIds: selectedRecordIds, _service })).unwrap() 97 | .then(recordsAfterDelete => { 98 | dataset.refresh(); 99 | dispatch(readdNewRowsAfterDelete(recordsAfterDelete.newRows)); 100 | }) 101 | .catch(error => { 102 | if (!error) { 103 | _service.openErrorDialog(error).then(() => { 104 | dispatch(setLoading(false)); 105 | }); 106 | } 107 | dispatch(setLoading(false)); 108 | }); 109 | }; 110 | 111 | const saveButtonHandler = () => { 112 | dispatch(setLoading(true)); 113 | dispatch(saveRecords(_service)).unwrap() 114 | .then(() => { 115 | dataset.refresh(); 116 | dispatch(removeNewRows()); 117 | }) 118 | .catch(error => 119 | _service.openErrorDialog(error).then(() => { 120 | dispatch(setLoading(false)); 121 | })); 122 | }; 123 | 124 | React.useEffect(() => { 125 | const datasetRows = [ 126 | ...newRows, 127 | ...mapDataSetRows(dataset), 128 | ]; 129 | dispatch(setRows(datasetRows)); 130 | dispatch(clearChangedRecords()); 131 | dispatch(readdChangedRecordsAfterDelete()); 132 | dispatch(setLoading(isPendingDelete || isPendingLoad)); 133 | _setContainerHeight(getContainerHeight(rows.length)); 134 | }, [dataset, isPendingLoad]); 135 | 136 | useLoadStore(dataset, _service); 137 | 138 | const _renderItemColumn = (item: Row, index: number | undefined, column: IColumn | undefined) => 139 | ; 140 | 141 | const sort = (sortDirection: ComponentFramework.PropertyHelper.DataSetApi.Types.SortDirection, 142 | column?: IColumn) => { 143 | if (column?.fieldName) { 144 | dispatch(setLoading(true)); 145 | const newSorting: ComponentFramework.PropertyHelper.DataSetApi.SortStatus = { 146 | name: column.fieldName, 147 | sortDirection, 148 | }; 149 | 150 | while (dataset.sorting.length > 0) { 151 | dataset.sorting.pop(); 152 | } 153 | dataset.sorting.push(newSorting); 154 | dataset.paging.reset(); 155 | dataset.refresh(); 156 | } 157 | }; 158 | 159 | const onHideSortMenu = React.useCallback(() => setSortMenuProps(undefined), []); 160 | 161 | const getSortMenuProps = 162 | (ev?: React.MouseEvent, column?: IColumn): IContextualMenuProps => { 163 | const items = [ 164 | { key: 'sortAsc', text: 'Sort Ascending', onClick: () => sort(ASC_SORT, column) }, 165 | { key: 'sortDesc', text: 'Sort Descending', onClick: () => sort(DESC_SORT, column) }, 166 | ]; 167 | return { 168 | items, 169 | target: ev?.currentTarget as HTMLElement, 170 | gapSpace: 2, 171 | isBeakVisible: false, 172 | directionalHint: DirectionalHint.bottomLeftEdge, 173 | onDismiss: onHideSortMenu, 174 | }; 175 | }; 176 | 177 | const _onColumnClick = (ev?: React.MouseEvent, column?: IColumn) => { 178 | if (column?.columnActionsMode !== ColumnActionsMode.disabled) { 179 | setSortMenuProps(getSortMenuProps(ev, column)); 180 | } 181 | }; 182 | 183 | return
184 | 185 | 193 | 194 | width ? 0 : width} 197 | items={rows} 198 | columns={columns} 199 | onRenderItemColumn={_renderItemColumn} 200 | selection={selection} 201 | onRenderRow={ (props, defaultRender) => 202 |
{ 203 | const target = event.target as HTMLInputElement; 204 | if (!target.className.includes('Button')) { 205 | _service.openForm(props?.item.key); 206 | } 207 | }}> 208 | {defaultRender!(props)} 209 |
} 210 | onRenderDetailsHeader={_onRenderDetailsHeader} 211 | layoutMode={DetailsListLayoutMode.fixedColumns} 212 | styles={gridStyles(rows.length)} 213 | onColumnHeaderClick={_onColumnClick} 214 | constrainMode={ ConstrainMode.unconstrained} 215 | > 216 |
217 | {sortMenuProps && } 218 | {rows.length === 0 && 219 | 220 |
No data available
221 |
222 | } 223 | 228 |
; 229 | }; 230 | -------------------------------------------------------------------------------- /EditableTable/components/EditableGrid/GridCell.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react'; 2 | import { IColumn } from '@fluentui/react'; 3 | 4 | import { LookupFormat } from '../InputComponents/LookupFormat'; 5 | import { NumberFormat } from '../InputComponents/NumberFormat'; 6 | import { OptionSetFormat } from '../InputComponents/OptionSetFormat'; 7 | import { DateTimeFormat } from '../InputComponents/DateTimeFormat'; 8 | import { WholeFormat } from '../InputComponents/WholeFormat'; 9 | 10 | import { Column, isNewRow, Row } from '../../mappers/dataSetMapper'; 11 | import { useAppDispatch, useAppSelector } from '../../store/hooks'; 12 | import { updateRow } from '../../store/features/DatasetSlice'; 13 | import { setChangedRecords } from '../../store/features/RecordSlice'; 14 | import { IDataverseService } from '../../services/DataverseService'; 15 | import { TextFormat } from '../InputComponents/TextFormat'; 16 | 17 | export interface IGridSetProps { 18 | row: Row, 19 | currentColumn: IColumn, 20 | _service: IDataverseService; 21 | index: number | undefined; 22 | } 23 | 24 | export type ParentEntityMetadata = { 25 | entityId: string, 26 | entityRecordName: string, 27 | entityTypeName: string 28 | }; 29 | 30 | export const GridCell = ({ _service, row, currentColumn, index }: IGridSetProps) => { 31 | const dispatch = useAppDispatch(); 32 | const cell = row.columns.find((column: Column) => column.schemaName === currentColumn.key); 33 | 34 | const fieldsRequirementLevels = useAppSelector(state => state.dataset.requirementLevels); 35 | const fieldRequirementLevel = fieldsRequirementLevels.find(requirementLevel => 36 | requirementLevel.fieldName === currentColumn.key); 37 | const isRequired = fieldRequirementLevel?.isRequired || false; 38 | 39 | const calculatedFields = useAppSelector(state => state.dataset.calculatedFields); 40 | const calculatedField = calculatedFields.find(field => 41 | field.fieldName === currentColumn.key); 42 | const isCalculatedField = calculatedField?.isCalculated || false; 43 | 44 | const securedFields = useAppSelector(state => state.dataset.securedFields); 45 | const securedField = securedFields.find(field => 46 | field.fieldName === currentColumn.key); 47 | let hasUpdateAccess = securedField?.hasUpdateAccess || false; 48 | 49 | let parentEntityMetadata: ParentEntityMetadata | undefined; 50 | let ownerEntityMetadata: string | undefined; 51 | if (isNewRow(row)) { 52 | parentEntityMetadata = _service.getParentMetadata(); 53 | ownerEntityMetadata = currentColumn.data === 'Lookup.Owner' 54 | ? _service.getCurrentUserName() : undefined; 55 | hasUpdateAccess = securedField?.hasCreateAccess || false; 56 | } 57 | 58 | const inactiveRecords = useAppSelector(state => state.dataset.inactiveRecords); 59 | const inactiveRecord = inactiveRecords.find(record => 60 | record.recordId === row.key); 61 | const isInactiveRecord = inactiveRecord?.isInactive || false; 62 | 63 | const _changedValue = useCallback( 64 | (newValue: any, rawValue?: any, lookupEntityNavigation?: string): void => { 65 | dispatch(setChangedRecords({ 66 | id: row.key, 67 | fieldName: lookupEntityNavigation || currentColumn.key, 68 | fieldType: currentColumn.data, 69 | newValue, 70 | })); 71 | 72 | dispatch(updateRow({ 73 | rowKey: row.key, 74 | columnName: currentColumn.key, 75 | newValue: rawValue ?? newValue, 76 | })); 77 | }, []); 78 | 79 | const props = { 80 | fieldName: currentColumn?.fieldName ? currentColumn?.fieldName : '', 81 | rowId: row.key, 82 | fieldId: `${currentColumn?.fieldName || ''}${row.key}`, 83 | formattedValue: cell?.formattedValue, 84 | isRequired, 85 | isDisabled: isInactiveRecord || isCalculatedField, 86 | isSecured: !hasUpdateAccess, 87 | _onChange: _changedValue, 88 | _service, 89 | index, 90 | ownerValue: ownerEntityMetadata, 91 | }; 92 | 93 | if (currentColumn !== undefined && cell !== undefined) { 94 | switch (currentColumn.data) { 95 | case 'DateAndTime.DateAndTime': 96 | return ; 97 | 98 | case 'DateAndTime.DateOnly': 99 | return ; 100 | 101 | case 'Lookup.Simple': 102 | return ; 104 | 105 | case 'Lookup.Customer': 106 | case 'Lookup.Owner': 107 | return ; 108 | 109 | case 'OptionSet': 110 | return ; 111 | 112 | case 'TwoOptions': 113 | return ; 115 | 116 | case 'MultiSelectPicklist': 117 | return ; 118 | 119 | case 'Decimal': 120 | return ; 121 | 122 | case 'Currency': 123 | return ; 124 | 125 | case 'FP': 126 | return ; 127 | 128 | case 'Whole.None': 129 | return ; 130 | 131 | case 'Whole.Duration': 132 | return ; 133 | 134 | case 'Whole.Language': 135 | return ; 136 | 137 | case 'Whole.TimeZone': 138 | return ; 139 | 140 | case 'SingleLine.Text': 141 | case 'Multiple': 142 | default: 143 | return ; 144 | } 145 | } 146 | 147 | return <>; 148 | }; 149 | -------------------------------------------------------------------------------- /EditableTable/components/EditableGrid/GridFooter.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { IconButton } from '@fluentui/react/lib/Button'; 3 | import { usePagination } from '../../hooks/usePagination'; 4 | import { BackIcon, footerButtonStyles, footerStyles, 5 | ForwardIcon, PreviousIcon } from '../../styles/FooterStyles'; 6 | 7 | type DataSet = ComponentFramework.PropertyTypes.DataSet; 8 | 9 | export interface IGridFooterProps { 10 | dataset: DataSet; 11 | selectedCount: number; 12 | resetScroll: () => void 13 | } 14 | 15 | export const GridFooter = ({ dataset, selectedCount, resetScroll } : IGridFooterProps) => { 16 | const { 17 | totalRecords, 18 | currentPage, 19 | hasPreviousPage, 20 | hasNextPage, 21 | firstItemNumber, 22 | lastItemNumber, 23 | moveToFirst, 24 | movePrevious, 25 | moveNext, 26 | } = usePagination(dataset); 27 | 28 | const selected = `${firstItemNumber} - ${lastItemNumber} of 29 | ${totalRecords === -1 ? '5000+' : totalRecords} 30 | ${selectedCount !== 0 ? `(${selectedCount} Selected)` : ''}`; 31 | 32 | return ( 33 |
34 | {selected} 35 |
36 | { 40 | resetScroll(); 41 | moveToFirst(); 42 | }} 43 | disabled={!hasPreviousPage} 44 | /> 45 | { 49 | resetScroll(); 50 | movePrevious(); 51 | }} 52 | disabled={!hasPreviousPage} 53 | /> 54 | Page {currentPage} 55 | { 59 | resetScroll(); 60 | moveNext(); 61 | }} 62 | disabled={!hasNextPage} 63 | /> 64 |
65 |
66 | ); 67 | }; 68 | -------------------------------------------------------------------------------- /EditableTable/components/ErrorIcon.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/display-name */ 2 | import { FontIcon, ITooltipProps, TooltipHost } from '@fluentui/react'; 3 | import React, { memo } from 'react'; 4 | import { error } from '../styles/ComponentsStyles'; 5 | import { useAppSelector } from '../store/hooks'; 6 | 7 | export interface IErrorProps { 8 | id: string; 9 | isRequired: boolean; 10 | } 11 | 12 | export const ErrorIcon = memo(({ id, isRequired } : IErrorProps) => { 13 | const invalidFields = useAppSelector(state => state.error.invalidFields); 14 | const invalidField = invalidFields.find(field => field.fieldId === id); 15 | 16 | const tooltipProps: ITooltipProps = { 17 | onRenderContent: () => 18 | 19 | {invalidField?.errorMessage} 20 | , 21 | }; 22 | 23 | return ( 24 | 29 | 33 | 34 | ); 35 | }); 36 | -------------------------------------------------------------------------------- /EditableTable/components/InputComponents/DateTimeFormat.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/display-name */ 2 | import React, { memo } from 'react'; 3 | import { 4 | DatePicker, 5 | defaultDatePickerStrings, 6 | Stack, 7 | ComboBox, 8 | IComboBox, 9 | IComboBoxOption, 10 | FontIcon, 11 | } from '@fluentui/react'; 12 | import { 13 | asteriskClassStyle, 14 | timePickerStyles, 15 | datePickerStyles, 16 | stackComboBox, 17 | } from '../../styles/ComponentsStyles'; 18 | import { useAppDispatch, useAppSelector } from '../../store/hooks'; 19 | import { shallowEqual } from 'react-redux'; 20 | import { 21 | getDateFormatWithHyphen, 22 | setTimeForDate, 23 | getTimeKeyFromTime, 24 | getTimeKeyFromDate, 25 | formatTimeto12Hours, 26 | } from '../../utils/dateTimeUtils'; 27 | import { 28 | formatUTCDateTimeToUserDate, 29 | formatUserDateTimeToUTC, 30 | formatDateShort, 31 | parseDateFromString, 32 | } from '../../utils/formattingUtils'; 33 | import { timesList } from './timeList'; 34 | import { IDataverseService } from '../../services/DataverseService'; 35 | import { ErrorIcon } from '../ErrorIcon'; 36 | import { setInvalidFields } from '../../store/features/ErrorSlice'; 37 | 38 | export interface IDatePickerProps { 39 | fieldId: string; 40 | fieldName: string; 41 | dateOnly: boolean; 42 | value: string | null; 43 | isDisabled: boolean; 44 | isRequired: boolean; 45 | isSecured: boolean; 46 | _onChange: any; 47 | _service: IDataverseService; 48 | } 49 | 50 | export const DateTimeFormat = memo(({ fieldName, fieldId, dateOnly, value, isDisabled, isSecured, 51 | isRequired, _onChange, _service }: IDatePickerProps) => { 52 | let timeKey: string | number | undefined; 53 | const options = [...timesList]; 54 | 55 | const dispatch = useAppDispatch(); 56 | const dateFields = useAppSelector(state => state.date.dates, shallowEqual); 57 | const currentDateMetadata = dateFields.find(dateField => dateField.fieldName === fieldName); 58 | const dateBehavior = currentDateMetadata?.dateBehavior ?? ''; 59 | 60 | let currentDate: Date | undefined = value 61 | ? dateBehavior === 'TimeZoneIndependent' 62 | ? formatUserDateTimeToUTC(_service, new Date(value), 4) 63 | : formatUTCDateTimeToUserDate(_service, value) 64 | : undefined; 65 | 66 | if (currentDate !== undefined && !isNaN(currentDate.getTime())) { 67 | const newKey = getTimeKeyFromDate(currentDate); 68 | timeKey = newKey; 69 | if (options.find(option => option.key === newKey) === undefined) { 70 | options.push({ 71 | key: newKey, 72 | text: formatTimeto12Hours(currentDate), 73 | }); 74 | } 75 | } 76 | else { 77 | timeKey = undefined; 78 | } 79 | 80 | const checkValidation = (newValue: Date | null | undefined) => { 81 | if (isRequired && (newValue === undefined || newValue === null || isNaN(newValue.getTime()))) { 82 | dispatch(setInvalidFields( 83 | { fieldId, isInvalid: true, errorMessage: 'Required fields must be filled in.' })); 84 | } 85 | else { 86 | dispatch(setInvalidFields({ fieldId, isInvalid: false, errorMessage: '' })); 87 | } 88 | }; 89 | 90 | const setChangedDateTime = (date: Date | undefined, key: string | number | undefined) => { 91 | const currentDateTime = setTimeForDate(date, key?.toString()); 92 | if (currentDateTime !== undefined) { 93 | if (dateBehavior === 'TimeZoneIndependent') { 94 | _onChange(`${getDateFormatWithHyphen(currentDateTime)}T${key ?? '00:00'}:00Z`); 95 | } 96 | else { 97 | const dateInUTC = formatUserDateTimeToUTC(_service, currentDateTime, 1); 98 | _onChange(`${getDateFormatWithHyphen(dateInUTC)}T${getTimeKeyFromDate(dateInUTC)}:00Z`); 99 | } 100 | } 101 | }; 102 | 103 | const onDateChange = (date: Date | null | undefined) => { 104 | if (date !== null && date !== undefined) { 105 | if (dateOnly) { 106 | currentDate = date; 107 | _onChange(`${getDateFormatWithHyphen(date)}T00:00:00Z`); 108 | } 109 | else { 110 | setChangedDateTime(date, timeKey); 111 | } 112 | } 113 | else if (!(currentDate === undefined && date === null)) { 114 | _onChange(null); 115 | } 116 | checkValidation(date); 117 | }; 118 | 119 | const onTimeChange = (event: React.FormEvent, option?: IComboBoxOption, 120 | index?: number, value?: string): void => { 121 | let key = option?.key; 122 | if (!option && value) { 123 | key = getTimeKeyFromTime(value); 124 | if (key !== '') { 125 | options.push({ key: key!, text: value.toUpperCase() }); 126 | } 127 | } 128 | timeKey = key; 129 | if (key) { 130 | setChangedDateTime(currentDate, key); 131 | } 132 | }; 133 | 134 | const localizedStrings = { 135 | ...defaultDatePickerStrings, 136 | shortDays: _service.getWeekDayNamesShort(), 137 | shortMonths: _service.getMonthNamesShort(), 138 | months: _service.getMonthNamesLong(), 139 | }; 140 | 141 | return ( 142 | 143 | date ? formatDateShort(_service, date) : ''} 148 | parseDateFromString={(newValue: string): Date => parseDateFromString(_service, newValue)} 149 | strings={localizedStrings} 150 | styles={datePickerStyles(dateOnly ? isRequired : false)} 151 | firstDayOfWeek={_service.getFirstDayOfWeek()} 152 | disabled={isDisabled || isSecured} 153 | onAfterMenuDismiss={() => checkValidation(currentDate)} 154 | onClick={() => dispatch(setInvalidFields({ fieldId, isInvalid: false, errorMessage: '' }))} 155 | title={currentDate?.toDateString()} 156 | /> 157 | {!dateOnly && 158 | checkValidation(currentDate)} 167 | /> 168 | } 169 | 170 | 171 | 172 | ); 173 | }); 174 | -------------------------------------------------------------------------------- /EditableTable/components/InputComponents/LookupFormat.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/display-name */ 2 | import { DefaultButton, FontIcon } from '@fluentui/react'; 3 | import { ITag, TagPicker } from '@fluentui/react/lib/Pickers'; 4 | import React, { memo } from 'react'; 5 | import { IDataverseService } from '../../services/DataverseService'; 6 | import { useAppDispatch, useAppSelector } from '../../store/hooks'; 7 | import { 8 | asteriskClassStyle, 9 | lookupFormatStyles, 10 | lookupSelectedOptionStyles, 11 | } from '../../styles/ComponentsStyles'; 12 | import { ParentEntityMetadata } from '../EditableGrid/GridCell'; 13 | import { ErrorIcon } from '../ErrorIcon'; 14 | import { setInvalidFields } from '../../store/features/ErrorSlice'; 15 | 16 | const MAX_NUMBER_OF_OPTIONS = 100; 17 | const SINGLE_CLICK_CODE = 1; 18 | 19 | export interface ILookupProps { 20 | fieldId: string; 21 | fieldName: string; 22 | value: ITag | undefined; 23 | parentEntityMetadata: ParentEntityMetadata | undefined; 24 | isRequired: boolean; 25 | isSecured: boolean; 26 | isDisabled: boolean; 27 | _onChange: Function; 28 | _service: IDataverseService; 29 | } 30 | 31 | export const LookupFormat = memo(({ fieldId, fieldName, value, parentEntityMetadata, 32 | isSecured, isRequired, isDisabled, _onChange, _service }: ILookupProps) => { 33 | const picker = React.useRef(null); 34 | const dispatch = useAppDispatch(); 35 | 36 | const lookups = useAppSelector(state => state.lookup.lookups); 37 | const currentLookup = lookups.find(lookup => lookup.logicalName === fieldName); 38 | const options = currentLookup?.options ?? []; 39 | const currentOption = value ? [value] : []; 40 | const isOffline = _service.isOffline(); 41 | 42 | if (value === undefined && 43 | parentEntityMetadata !== undefined && parentEntityMetadata.entityId !== undefined) { 44 | if (currentLookup?.reference?.entityNameRef === parentEntityMetadata.entityTypeName) { 45 | currentOption.push({ 46 | key: parentEntityMetadata.entityId, 47 | name: parentEntityMetadata.entityRecordName, 48 | }); 49 | 50 | _onChange(`/${currentLookup?.entityPluralName}(${parentEntityMetadata.entityId})`, 51 | currentOption[0], 52 | currentLookup?.reference?.entityNavigation); 53 | } 54 | } 55 | 56 | const initialValues = (): ITag[] => { 57 | if (options.length > MAX_NUMBER_OF_OPTIONS) { 58 | return options.slice(0, MAX_NUMBER_OF_OPTIONS); 59 | } 60 | return options; 61 | }; 62 | 63 | const filterSuggestedTags = (filterText: string): ITag[] => { 64 | if (filterText.length === 0) return []; 65 | 66 | return options.filter(tag => { 67 | if (tag.name === null) return false; 68 | 69 | return tag.name.toLowerCase().includes(filterText.toLowerCase()); 70 | }); 71 | }; 72 | 73 | const onChange = (items?: ITag[] | undefined): void => { 74 | if (items !== undefined && items.length > 0) { 75 | _onChange(`/${currentLookup?.entityPluralName}(${items[0].key})`, items[0], 76 | currentLookup?.reference?.entityNavigation); 77 | } 78 | else { 79 | _onChange(null, null, currentLookup?.reference?.entityNavigation); 80 | } 81 | }; 82 | 83 | const _onRenderItem = () => 84 | onChange(undefined)} 93 | onClick={event => { 94 | if (event.detail === SINGLE_CLICK_CODE) { 95 | _service.openForm(currentOption[0].key.toString(), 96 | currentLookup?.reference?.entityNameRef); 97 | } 98 | }} 99 | styles={lookupSelectedOptionStyles} 100 | />; 101 | 102 | return
103 | { 115 | if (picker.current) { 116 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 117 | // @ts-ignore 118 | picker.current.input.current._updateValue(''); 119 | dispatch(setInvalidFields({ fieldId, isInvalid: isRequired, 120 | errorMessage: 'Required fields must be filled in' })); 121 | } 122 | }} 123 | disabled={isSecured || isDisabled || isOffline} 124 | inputProps={{ 125 | onFocus: () => dispatch(setInvalidFields({ fieldId, isInvalid: false, errorMessage: '' })), 126 | }} 127 | /> 128 | 129 | 130 |
; 131 | }); 132 | -------------------------------------------------------------------------------- /EditableTable/components/InputComponents/NumberFormat.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/display-name */ 2 | import { FontIcon, SpinButton, Stack } from '@fluentui/react'; 3 | import React, { memo } from 'react'; 4 | import { IDataverseService } from '../../services/DataverseService'; 5 | import { useAppDispatch, useAppSelector } from '../../store/hooks'; 6 | import { 7 | asteriskClassStyle, 8 | numberFormatStyles, 9 | } from '../../styles/ComponentsStyles'; 10 | import { formatCurrency, formatDecimal, formatNumber } from '../../utils/formattingUtils'; 11 | import { CurrencySymbol, NumberFieldMetadata } from '../../store/features/NumberSlice'; 12 | import { ErrorIcon } from '../ErrorIcon'; 13 | import { setInvalidFields } from '../../store/features/ErrorSlice'; 14 | 15 | export interface INumberProps { 16 | fieldId: string; 17 | fieldName: string | undefined; 18 | value: string; 19 | rowId?: string; 20 | isRequired: boolean; 21 | isDisabled: boolean; 22 | isSecured: boolean; 23 | _onChange: Function; 24 | _service: IDataverseService; 25 | } 26 | 27 | export const NumberFormat = memo(({ fieldId, fieldName, value, rowId, isRequired, isDisabled, 28 | isSecured, _onChange, _service } : INumberProps) => { 29 | const dispatch = useAppDispatch(); 30 | 31 | const numbers = useAppSelector(state => state.number.numberFieldsMetadata); 32 | const currencySymbols = useAppSelector(state => state.number.currencySymbols); 33 | const changedRecords = useAppSelector(state => state.record.changedRecords); 34 | const changedRecord = changedRecords.find(transaction => transaction.id === rowId); 35 | const changedTransactionId = changedRecord?.data.find(data => 36 | data.fieldName === 'transactioncurrencyid'); 37 | 38 | let currentCurrency: CurrencySymbol | null = null; 39 | const currentNumber = numbers.find(num => num.fieldName === fieldName); 40 | if (changedTransactionId?.newValue) { 41 | const transactionId = changedTransactionId.newValue.match(/\(([^)]+)\)/)[1]; 42 | _service.getCurrencyById(transactionId).then(result => { 43 | currentCurrency = { recordId: rowId || '', 44 | symbol: result.symbol, 45 | precision: result.precision }; 46 | }); 47 | } 48 | 49 | if (currentCurrency === null) { 50 | currentCurrency = currencySymbols.find(currency => currency.recordId === rowId) ?? null; 51 | } 52 | 53 | function changeNumberFormat(currentCurrency: CurrencySymbol | null, 54 | currentNumber: NumberFieldMetadata | undefined, 55 | precision: number | undefined, 56 | newValue?: string) { 57 | const numberValue = formatNumber(_service, newValue!); 58 | const stringValue = currentCurrency && currentNumber?.isBaseCurrency !== undefined 59 | ? formatCurrency(_service, numberValue || 0, 60 | precision, currentCurrency?.symbol) 61 | : formatDecimal(_service, numberValue || 0, currentNumber?.precision); 62 | _onChange(numberValue, stringValue); 63 | } 64 | 65 | const onNumberChange = (newValue?: string) => { 66 | if (newValue === '') { 67 | _onChange(null, ''); 68 | } 69 | else if (currentCurrency && currentNumber) { 70 | if (currentNumber?.precision === 2) { 71 | changeNumberFormat(currentCurrency, currentNumber, currentCurrency.precision, newValue); 72 | } 73 | else { 74 | changeNumberFormat(currentCurrency, currentNumber, currentNumber.precision, newValue); 75 | } 76 | } 77 | else { 78 | changeNumberFormat(currentCurrency, currentNumber, currentNumber?.precision, newValue); 79 | } 80 | }; 81 | 82 | const checkValidation = (newValue: string) => { 83 | if (isRequired && !newValue) { 84 | dispatch(setInvalidFields({ fieldId, isInvalid: true, 85 | errorMessage: 'Required fields must be filled in.' })); 86 | } 87 | }; 88 | 89 | return ( 90 | 91 | ) => { 100 | const elem = event.target as HTMLInputElement; 101 | if (value !== elem.value) { 102 | onNumberChange(elem.value); 103 | } 104 | checkValidation(elem.value); 105 | }} 106 | onFocus={() => dispatch(setInvalidFields({ fieldId, isInvalid: false, errorMessage: '' }))} 107 | /> 108 | 109 | 110 | 111 | ); 112 | }); 113 | -------------------------------------------------------------------------------- /EditableTable/components/InputComponents/OptionSetFormat.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/display-name */ 2 | import React, { memo } from 'react'; 3 | import { Stack, ComboBox, IComboBox, IComboBoxOption, FontIcon } from '@fluentui/react'; 4 | 5 | import { useAppDispatch, useAppSelector } from '../../store/hooks'; 6 | import { asteriskClassStyle, optionSetStyles } from '../../styles/ComponentsStyles'; 7 | import { IDataverseService } from '../../services/DataverseService'; 8 | import { ErrorIcon } from '../ErrorIcon'; 9 | import { setInvalidFields } from '../../store/features/ErrorSlice'; 10 | 11 | export interface IDropDownProps { 12 | fieldId: string; 13 | fieldName: string | undefined; 14 | value: string | null; 15 | formattedValue: string | undefined; 16 | isMultiple: boolean; 17 | isTwoOptions?: boolean; 18 | _onChange: Function; 19 | isRequired: boolean; 20 | isDisabled: boolean; 21 | isSecured: boolean; 22 | _service: IDataverseService; 23 | } 24 | 25 | export const OptionSetFormat = memo(({ fieldId, fieldName, value, formattedValue, isMultiple, 26 | isRequired, isTwoOptions, isDisabled, isSecured, _onChange, _service }: IDropDownProps) => { 27 | let currentValue = value; 28 | const dispatch = useAppDispatch(); 29 | 30 | const dropdowns = useAppSelector(state => state.dropdown.dropdownFields); 31 | const currentDropdown = dropdowns.find(dropdown => dropdown.fieldName === fieldName); 32 | let options = currentDropdown?.options ?? []; 33 | const disabled = fieldName === 'statuscode' || fieldName === 'statecode' || isDisabled; 34 | 35 | if (_service.isStatusField(fieldName) && !currentValue) { 36 | currentValue = options.find(option => 37 | option.text.toLowerCase().includes('active'))?.key.toString() || ''; 38 | } 39 | const currentOptions: string[] = currentValue ? currentValue.split(',') : []; 40 | if (isSecured && options.length < 1) { 41 | options = currentOptions.map(opt => ({ 42 | key: opt, text: formattedValue || '', 43 | })); 44 | } 45 | 46 | const onChange = 47 | (event: React.FormEvent, option?: IComboBoxOption | undefined) => { 48 | if (isMultiple) { 49 | if (option?.selected) { 50 | _onChange([...currentOptions, option.key as string].join(', '), 51 | [...currentOptions, option.key as string].join(',')); 52 | } 53 | else { 54 | _onChange(currentOptions.filter(key => key !== option?.key).join(', ') || null, 55 | currentOptions.filter(key => key !== option?.key).join(',') || null); 56 | } 57 | } 58 | else if (isTwoOptions) { 59 | _onChange(option?.key === '1', option!.key.toString()); 60 | } 61 | else { 62 | _onChange(option?.key, option!.key.toString()); 63 | } 64 | }; 65 | 66 | const checkValidation = () => { 67 | if (isRequired && currentOptions.length < 1) { 68 | dispatch(setInvalidFields({ fieldId, isInvalid: true, 69 | errorMessage: 'Required fields must be filled in.' })); 70 | } 71 | }; 72 | 73 | return ( 74 | 75 | checkValidation()} 83 | onMenuOpen={() => 84 | dispatch(setInvalidFields({ fieldId, isInvalid: false, errorMessage: '' }))} 85 | disabled={disabled || isSecured} 86 | title={formattedValue} 87 | /> 88 | 89 | 90 | 91 | ); 92 | }); 93 | -------------------------------------------------------------------------------- /EditableTable/components/InputComponents/TextFormat.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/display-name */ 2 | import { FontIcon, Stack, TextField } from '@fluentui/react'; 3 | import React, { memo } from 'react'; 4 | import { asteriskClassStyle, textFieldStyles } from '../../styles/ComponentsStyles'; 5 | import { useAppDispatch, useAppSelector } from '../../store/hooks'; 6 | import { setInvalidFields } from '../../store/features/ErrorSlice'; 7 | import { isEmailValid, validateUrl } from '../../utils/textUtils'; 8 | import { ErrorIcon } from '../ErrorIcon'; 9 | 10 | export type errorProp = { 11 | isInvalid: boolean, 12 | errorText: string 13 | }; 14 | 15 | export interface ITextProps { 16 | fieldId: string; 17 | fieldName: string; 18 | value: string | undefined; 19 | ownerValue: string | undefined; 20 | type?: string; 21 | isDisabled: boolean; 22 | isRequired: boolean; 23 | isSecured: boolean; 24 | _onChange: Function; 25 | } 26 | 27 | export const TextFormat = memo(({ fieldId, value, isRequired, isDisabled, type, isSecured, 28 | fieldName, ownerValue, _onChange } : ITextProps) => { 29 | const currentValue = ownerValue !== undefined ? ownerValue : value; 30 | const dispatch = useAppDispatch(); 31 | const textFields = useAppSelector(state => state.text.textFields); 32 | const currentTextField = textFields.find(textField => textField.fieldName === fieldName); 33 | 34 | const onChange = (newValue: string) => { 35 | if (type?.includes('URL')) { 36 | _onChange(validateUrl(newValue)); 37 | } 38 | else { 39 | _onChange(newValue); 40 | } 41 | }; 42 | 43 | const checkValidation = (newValue: string) => { 44 | if (isRequired && newValue === '') { 45 | dispatch(setInvalidFields( 46 | { fieldId, isInvalid: true, errorMessage: 'Required fields must be filled in.' })); 47 | } 48 | else if (currentTextField?.textMaxLength && newValue.length > currentTextField?.textMaxLength) { 49 | dispatch(setInvalidFields( 50 | { fieldId, isInvalid: true, 51 | errorMessage: 'You have exceeded the maximum number of characters in this field.' })); 52 | } 53 | else if (type?.includes('Email') && !isEmailValid(newValue) && newValue !== '') { 54 | dispatch(setInvalidFields( 55 | { fieldId, isInvalid: true, errorMessage: 'Enter a valid email address.' })); 56 | } 57 | else if (!isRequired && newValue === '') { 58 | dispatch(setInvalidFields({ fieldId, isInvalid: false, errorMessage: '' })); 59 | } 60 | else { 61 | dispatch(setInvalidFields({ fieldId, isInvalid: false, errorMessage: '' })); 62 | } 63 | }; 64 | 65 | return ( 66 | 67 | ) => { 73 | const elem = event.target as HTMLInputElement; 74 | if (currentValue !== elem.value) { 75 | onChange(elem.value); 76 | } 77 | checkValidation(elem.value); 78 | }} 79 | onFocus={() => dispatch(setInvalidFields({ fieldId, isInvalid: false, errorMessage: '' }))} 80 | /> 81 | 82 | 83 | 84 | ); 85 | }); 86 | -------------------------------------------------------------------------------- /EditableTable/components/InputComponents/WholeFormat.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/display-name */ 2 | import { ComboBox, FontIcon, IComboBox, IComboBoxOption, Stack } from '@fluentui/react'; 3 | import React, { memo } from 'react'; 4 | import { useAppDispatch, useAppSelector } from '../../store/hooks'; 5 | import { asteriskClassStyle, wholeFormatStyles } from '../../styles/ComponentsStyles'; 6 | import { getDurationOption } from '../../utils/durationUtils'; 7 | import { durationList } from './durationList'; 8 | import { ErrorIcon } from '../ErrorIcon'; 9 | import { setInvalidFields } from '../../store/features/ErrorSlice'; 10 | 11 | export interface IWholeFormatProps { 12 | fieldId: string; 13 | value: string | null | undefined; 14 | formattedValue?: string; 15 | type: string; 16 | _onChange: Function; 17 | isRequired: boolean; 18 | isDisabled: boolean; 19 | isSecured: boolean 20 | } 21 | 22 | export const WholeFormat = memo(({ fieldId, value, formattedValue, type, isDisabled, isSecured, 23 | isRequired, _onChange } : IWholeFormatProps) => { 24 | const dispatch = useAppDispatch(); 25 | const wholeFormat = useAppSelector(state => state.wholeFormat); 26 | 27 | let options: IComboBoxOption[] = []; 28 | if (!isSecured) { 29 | switch (type) { 30 | case 'timezone': 31 | options = wholeFormat.timezones; 32 | break; 33 | 34 | case 'language': 35 | options = wholeFormat.languages; 36 | break; 37 | 38 | case 'duration': 39 | options = [ 40 | { key: value, text: formattedValue, hidden: true } as IComboBoxOption, 41 | ...durationList, 42 | ]; 43 | break; 44 | } 45 | } 46 | else { 47 | options = [ { key: value, text: formattedValue, hidden: true } as IComboBoxOption]; 48 | } 49 | 50 | const durationValidation = (value: string | undefined): IComboBoxOption | undefined => { 51 | if (type === 'duration' && value) { 52 | const newOption = getDurationOption(value) as IComboBoxOption; 53 | if (newOption) { 54 | options.push(newOption); 55 | return newOption; 56 | } 57 | } 58 | }; 59 | 60 | const onChange = (event: React.FormEvent, option?: IComboBoxOption, 61 | index?: number | undefined, value?: string | undefined): void => { 62 | if (option) { 63 | const { key } = option; 64 | type === 'duration' 65 | ? _onChange(key, option.text) 66 | : _onChange(key); 67 | } 68 | else { 69 | const newOption = durationValidation(value); 70 | _onChange(newOption?.key || null, newOption?.text); 71 | } 72 | }; 73 | 74 | const checkValidation = () => { 75 | if (isRequired && (value === '' || value === null)) { 76 | dispatch(setInvalidFields({ fieldId, isInvalid: true, 77 | errorMessage: 'Required fields must be filled in.' })); 78 | } 79 | }; 80 | 81 | return ( 82 | 83 | checkValidation()} 93 | onFocus={() => dispatch(setInvalidFields({ fieldId, isInvalid: false, errorMessage: '' }))} 94 | /> 95 | 96 | 97 | 98 | ); 99 | }); 100 | -------------------------------------------------------------------------------- /EditableTable/components/InputComponents/durationList.ts: -------------------------------------------------------------------------------- 1 | import { IComboBoxOption } from '@fluentui/react'; 2 | 3 | export const durationList: IComboBoxOption[] = [ 4 | { text: '1 minute', key: '1' }, 5 | { text: '15 minutes', key: '15' }, 6 | { text: '30 minutes', key: '30' }, 7 | { text: '45 minutes', key: '45' }, 8 | { text: '1 hour', key: '60' }, 9 | { text: '1.5 hours', key: '90' }, 10 | { text: '2 hours', key: '120' }, 11 | { text: '2.5 hours', key: '150' }, 12 | { text: '3 hours', key: '180' }, 13 | { text: '3.5 hours', key: '210' }, 14 | { text: '4 hours', key: '240' }, 15 | { text: '4.5 hours', key: '270' }, 16 | { text: '5 hours', key: '300' }, 17 | { text: '5.5 hours', key: '330' }, 18 | { text: '6 hours', key: '360' }, 19 | { text: '6.5 hours', key: '390' }, 20 | { text: '7 hours', key: '420' }, 21 | { text: '7.5 hours', key: '450' }, 22 | { text: '8 hours', key: '480' }, 23 | { text: '1 day', key: '1440' }, 24 | { text: '2 days', key: '2880' }, 25 | { text: '3 days', key: '4320' }, 26 | ]; 27 | -------------------------------------------------------------------------------- /EditableTable/components/InputComponents/timeList.ts: -------------------------------------------------------------------------------- 1 | import { IComboBoxOption } from '@fluentui/react'; 2 | 3 | export const timesList: IComboBoxOption[] = [ 4 | { text: '12:00 AM', key: '00:00' }, 5 | { text: '12:30 AM', key: '00:30' }, 6 | { text: '1:00 AM', key: '01:00' }, 7 | { text: '1:30 AM', key: '01:30' }, 8 | { text: '2:00 AM', key: '02:00' }, 9 | { text: '2:30 AM', key: '02:30' }, 10 | { text: '3:00 AM', key: '03:00' }, 11 | { text: '3:30 AM', key: '03:30' }, 12 | { text: '4:00 AM', key: '04:00' }, 13 | { text: '4:30 AM', key: '04:30' }, 14 | { text: '5:00 AM', key: '05:00' }, 15 | { text: '5:30 AM', key: '05:30' }, 16 | { text: '6:00 AM', key: '06:00' }, 17 | { text: '6:30 AM', key: '06:30' }, 18 | { text: '7:00 AM', key: '07:00' }, 19 | { text: '7:30 AM', key: '07:30' }, 20 | { text: '8:00 AM', key: '08:00' }, 21 | { text: '8:30 AM', key: '08:30' }, 22 | { text: '9:00 AM', key: '09:00' }, 23 | { text: '9:30 AM', key: '09:30' }, 24 | { text: '10:00 AM', key: '10:00' }, 25 | { text: '10:30 AM', key: '10:30' }, 26 | { text: '11:00 AM', key: '11:00' }, 27 | { text: '11:30 AM', key: '11:30' }, 28 | { text: '12:00 PM', key: '12:00' }, 29 | { text: '12:30 PM', key: '12:30' }, 30 | { text: '1:00 PM', key: '13:00' }, 31 | { text: '1:30 PM', key: '13:30' }, 32 | { text: '2:00 PM', key: '14:00' }, 33 | { text: '2:30 PM', key: '14:30' }, 34 | { text: '3:00 PM', key: '15:00' }, 35 | { text: '3:30 PM', key: '15:30' }, 36 | { text: '4:00 PM', key: '16:00' }, 37 | { text: '4:30 PM', key: '16:30' }, 38 | { text: '5:00 PM', key: '17:00' }, 39 | { text: '5:30 PM', key: '17:30' }, 40 | { text: '6:00 PM', key: '18:00' }, 41 | { text: '6:30 PM', key: '18:30' }, 42 | { text: '7:00 PM', key: '19:00' }, 43 | { text: '7:30 PM', key: '19:30' }, 44 | { text: '8:00 PM', key: '20:00' }, 45 | { text: '8:30 PM', key: '20:30' }, 46 | { text: '9:00 PM', key: '21:00' }, 47 | { text: '9:30 PM', key: '21:30' }, 48 | { text: '10:00 PM', key: '22:00' }, 49 | { text: '10:30 PM', key: '22:30' }, 50 | { text: '11:00 PM', key: '23:00' }, 51 | { text: '11:30 PM', key: '23:30' }, 52 | ]; 53 | -------------------------------------------------------------------------------- /EditableTable/components/Loading.tsx: -------------------------------------------------------------------------------- 1 | import { Spinner, SpinnerSize, Stack } from '@fluentui/react'; 2 | import * as React from 'react'; 3 | import { useAppSelector } from '../store/hooks'; 4 | import { loadingStyles } from '../styles/ComponentsStyles'; 5 | 6 | export const Loading = () => { 7 | const loading = useAppSelector(state => state.loading.isLoading); 8 | 9 | return ( 10 | 11 | 13 | 14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /EditableTable/hooks/useLoadStore.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { setRelationships, setLookups } from '../store/features/LookupSlice'; 4 | import { getDropdownsOptions } from '../store/features/DropdownSlice'; 5 | import { getCurrencySymbols, getNumberFieldsMetadata } from '../store/features/NumberSlice'; 6 | import { getLanguages, getTimeZones } from '../store/features/WholeFormatSlice'; 7 | import { getDateBehavior } from '../store/features/DateSlice'; 8 | import { setLoading } from '../store/features/LoadingSlice'; 9 | 10 | import { useAppDispatch } from '../store/hooks'; 11 | 12 | import { mapDataSetColumns, mapDataSetRows } from '../mappers/dataSetMapper'; 13 | import { 14 | setCalculatedFields, 15 | setEntityPrivileges, 16 | setInactiveRecords, 17 | setRequirementLevels, 18 | setSecuredFields, 19 | } from '../store/features/DatasetSlice'; 20 | import { IDataverseService } from '../services/DataverseService'; 21 | import { getTextMetadata } from '../store/features/TextSlice'; 22 | 23 | type DataSet = ComponentFramework.PropertyTypes.DataSet; 24 | 25 | export type Field = { 26 | key: string, 27 | fieldName: string | undefined, 28 | data: string | undefined, 29 | } 30 | 31 | export const useLoadStore = (dataset: DataSet, _service: IDataverseService) => { 32 | const dispatch = useAppDispatch(); 33 | 34 | React.useEffect(() => { 35 | const columns = mapDataSetColumns(dataset, _service); 36 | const datasetRows = mapDataSetRows(dataset); 37 | const recordIds = datasetRows.map(row => row.key); 38 | 39 | const columnKeys = columns.filter(column => !column.key.includes('.')) 40 | .map(column => column.key); 41 | 42 | const getColumnsOfType = (types: string[]): Field[] => 43 | columns.filter(column => types.includes(column.data) && !column.key.includes('.')) 44 | .map(column => ({ 45 | key: column.key, 46 | fieldName: column.fieldName, 47 | data: column.data, 48 | })); 49 | 50 | const textFields = getColumnsOfType(['SingleLine.Text', 'Multiple']); 51 | if (textFields.length > 0) { 52 | dispatch(getTextMetadata({ textFields, _service })).unwrap() 53 | .catch(error => 54 | _service.openErrorDialog(error).then(() => { 55 | dispatch(setLoading(false)); 56 | })); 57 | } 58 | 59 | const lookupColumns = getColumnsOfType(['Lookup.Simple']); 60 | if (lookupColumns.length > 0) { 61 | dispatch(setRelationships(_service)).unwrap() 62 | .then(() => { 63 | dispatch(setLookups({ lookupColumns, _service })).unwrap() 64 | .catch(error => 65 | _service.openErrorDialog(error).then(() => { 66 | dispatch(setLoading(false)); 67 | })); 68 | }) 69 | .catch(error => 70 | _service.openErrorDialog(error).then(() => { 71 | dispatch(setLoading(false)); 72 | })); 73 | } 74 | 75 | const dropdownFields = getColumnsOfType(['OptionSet', 'TwoOptions', 'MultiSelectPicklist']); 76 | if (dropdownFields.length > 0) { 77 | dispatch(getDropdownsOptions({ dropdownFields, _service })).unwrap() 78 | .catch(error => 79 | _service.openErrorDialog(error).then(() => { 80 | dispatch(setLoading(false)); 81 | })); 82 | } 83 | 84 | const numberFields = getColumnsOfType(['Decimal', 'Currency', 'FP', 'Whole.None']); 85 | if (numberFields.length > 0) { 86 | dispatch(getNumberFieldsMetadata({ numberFields, _service })); 87 | 88 | if (numberFields.some(numberColumn => numberColumn.data === 'Currency')) { 89 | dispatch(getCurrencySymbols({ recordIds, _service })); 90 | } 91 | } 92 | 93 | const timezoneColumns = getColumnsOfType(['Whole.TimeZone']); 94 | if (timezoneColumns.length > 0) { 95 | dispatch(getTimeZones(_service)); 96 | } 97 | 98 | const languageColumns = getColumnsOfType(['Whole.Language']); 99 | if (languageColumns.length > 0) { 100 | dispatch(getLanguages(_service)); 101 | } 102 | 103 | const dateFields = getColumnsOfType(['DateAndTime.DateAndTime', 'DateAndTime.DateOnly']); 104 | if (dateFields.length > 0) { 105 | dispatch(getDateBehavior({ dateFields, _service })); 106 | } 107 | 108 | dispatch(setRequirementLevels({ columnKeys, _service })); 109 | dispatch(setCalculatedFields({ columnKeys, _service })); 110 | dispatch(setSecuredFields({ columnKeys, _service })); 111 | dispatch(setEntityPrivileges(_service)); 112 | dispatch(setInactiveRecords({ recordIds, _service })); 113 | 114 | }, [dataset]); 115 | }; 116 | -------------------------------------------------------------------------------- /EditableTable/hooks/usePagination.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { setLoading } from '../store/features/LoadingSlice'; 3 | import { useAppDispatch } from '../store/hooks'; 4 | 5 | type DataSet = ComponentFramework.PropertyTypes.DataSet; 6 | 7 | export const usePagination = (dataset: DataSet) => { 8 | const [currentPage, setCurrentPage] = React.useState(1); 9 | 10 | const dispatch = useAppDispatch(); 11 | 12 | const { 13 | totalResultCount: totalRecords, 14 | pageSize, 15 | hasNextPage, 16 | hasPreviousPage, 17 | } = dataset.paging; 18 | 19 | const totalPages = totalRecords !== -1 20 | ? Math.ceil(totalRecords / pageSize) 21 | : 5000; 22 | 23 | const firstItemNumber = totalPages === 0 24 | ? 0 25 | : (currentPage - 1) * pageSize + 1; 26 | const lastItemNumber = totalPages === 0 27 | ? 0 28 | : currentPage !== totalPages 29 | ? (currentPage - 1) * pageSize + pageSize 30 | : totalRecords; 31 | 32 | React.useEffect(() => { 33 | if (currentPage !== dataset.paging.firstPageNumber) { 34 | setCurrentPage(dataset.paging.firstPageNumber); 35 | } 36 | }, [dataset.paging.firstPageNumber]); 37 | 38 | function moveToPage(pageNumber: number) { 39 | dispatch(setLoading(true)); 40 | setCurrentPage(pageNumber); 41 | dataset.paging.loadExactPage(pageNumber); 42 | } 43 | 44 | function moveToFirst() { 45 | moveToPage(1); 46 | } 47 | 48 | function movePrevious() { 49 | moveToPage(currentPage - 1); 50 | } 51 | 52 | function moveNext() { 53 | moveToPage(currentPage + 1); 54 | } 55 | 56 | return { 57 | totalRecords, 58 | currentPage, 59 | hasPreviousPage, 60 | hasNextPage, 61 | firstItemNumber, 62 | lastItemNumber, 63 | moveToFirst, 64 | movePrevious, 65 | moveNext, 66 | }; 67 | }; 68 | -------------------------------------------------------------------------------- /EditableTable/hooks/useSelection.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Selection } from '@fluentui/react/lib/DetailsList'; 3 | 4 | type Entity = ComponentFramework.WebApi.Entity; 5 | 6 | export const useSelection = () => { 7 | const [selectedRecordIds, setSelectedRecordIds] = React.useState([]); 8 | 9 | const selection = new Selection({ 10 | onSelectionChanged: () => { 11 | const recordIds = selection.getSelection() 12 | .map((row : Entity) => row.key); 13 | 14 | setSelectedRecordIds(recordIds); 15 | }, 16 | }); 17 | 18 | return { 19 | selection, 20 | selectedRecordIds, 21 | }; 22 | }; 23 | -------------------------------------------------------------------------------- /EditableTable/index.ts: -------------------------------------------------------------------------------- 1 | import { IInputs, IOutputs } from './generated/ManifestTypes'; 2 | import * as React from 'react'; 3 | import { DataverseService, IDataverseService } from './services/DataverseService'; 4 | import { Wrapper } from './components/AppWrapper'; 5 | import { Store } from './utils/types'; 6 | import { callConfigureStore } from './store/store'; 7 | 8 | export class EditableTable implements ComponentFramework.ReactControl { 9 | private notifyOutputChanged: () => void; 10 | private _service: IDataverseService; 11 | public _store: Store; 12 | 13 | constructor() { } 14 | 15 | public init( 16 | context: ComponentFramework.Context, 17 | notifyOutputChanged: () => void, 18 | ): void { 19 | this.notifyOutputChanged = notifyOutputChanged; 20 | this._service = new DataverseService(context); 21 | context.mode.trackContainerResize(true); 22 | this._store = callConfigureStore(); 23 | } 24 | 25 | public updateView(context: ComponentFramework.Context): React.ReactElement { 26 | if (context.mode.allocatedWidth > 0) { 27 | return React.createElement(Wrapper, { 28 | dataset: context.parameters.dataset, 29 | isControlDisabled: context.mode.isControlDisabled, 30 | width: context.mode.allocatedWidth, 31 | _service: this._service, 32 | _store: this._store, 33 | _setContainerHeight: () => {}, 34 | }); 35 | } 36 | return React.createElement('div'); 37 | } 38 | 39 | public getOutputs(): IOutputs { return {}; } 40 | 41 | public destroy(): void { } 42 | } 43 | -------------------------------------------------------------------------------- /EditableTable/mappers/dataSetMapper.tsx: -------------------------------------------------------------------------------- 1 | import { ColumnActionsMode, IColumn, ITag, TooltipHost } from '@fluentui/react'; 2 | import { IDataverseService } from '../services/DataverseService'; 3 | import { NEW_RECORD_ID_LENGTH_CHECK } from '../utils/commonUtils'; 4 | import React from 'react'; 5 | type DataSet = ComponentFramework.PropertyTypes.DataSet; 6 | 7 | export type Row = { 8 | key: string, 9 | columns: Column[] 10 | }; 11 | 12 | export type Column = { 13 | schemaName: string, 14 | formattedValue: string, 15 | rawValue: string | null, 16 | lookup?: ITag, 17 | type: string, 18 | }; 19 | 20 | const SELECTION_WIDTH = 48; 21 | const PADDING_WIDTH = 16; 22 | const EXCESS_WIDTH = 20; 23 | 24 | export const isNewRow = (row: Row) => row.key.length < NEW_RECORD_ID_LENGTH_CHECK; 25 | 26 | export const getColumnsTotalWidth = (dataset: DataSet) => 27 | dataset.columns.reduce((result, column) => result + column.visualSizeFactor, 0); 28 | 29 | const calculateAdditinalWidth = 30 | (dataset: DataSet, columnTotalWidth: number, tableWidth: number) => { 31 | const widthDiff = tableWidth - columnTotalWidth; 32 | const columnCount = dataset.columns.length; 33 | 34 | if (widthDiff > 0) { 35 | return Math.floor((widthDiff / columnCount) - 36 | (SELECTION_WIDTH + PADDING_WIDTH * columnCount) / columnCount) - EXCESS_WIDTH; 37 | } 38 | return 0; 39 | }; 40 | 41 | export const mapDataSetColumns = 42 | (dataset: DataSet, _service: IDataverseService): IColumn[] => { 43 | const columnTotalWidth = getColumnsTotalWidth(dataset); 44 | const tableWidth = _service.getAllocatedWidth(); 45 | const sortingColumns = dataset.sorting; 46 | 47 | return dataset.columns 48 | .sort((column1, column2) => column1.order - column2.order) 49 | .filter(column => !column.isHidden) 50 | .map((column): IColumn => ({ 51 | name: column.displayName, 52 | fieldName: column.name, 53 | minWidth: column.dataType === 'DateAndTime.DateAndTime' 54 | ? 55 : 20, 55 | key: column.name, 56 | isResizable: true, 57 | data: column.dataType, 58 | calculatedWidth: column.visualSizeFactor + 59 | calculateAdditinalWidth(dataset, columnTotalWidth, tableWidth), 60 | isSorted: sortingColumns.some(col => col.name === column.name), 61 | isSortedDescending: sortingColumns.find(col => col.name === column.name)?.sortDirection === 1, 62 | showSortIconWhenUnsorted: true, 63 | ariaLabel: column.displayName, 64 | columnActionsMode: column.dataType === 'MultiSelectPicklist' 65 | ? ColumnActionsMode.disabled : ColumnActionsMode.hasDropdown, 66 | onRenderHeader: () => <> 67 | 68 | {column.displayName} 69 | 70 | , 71 | })); 72 | }; 73 | 74 | export const mapDataSetRows = (dataset: DataSet): Row[] => 75 | dataset.sortedRecordIds.map(id => { 76 | const record = dataset.records[id]; 77 | 78 | const columns = dataset.columns.map(column => ({ 79 | schemaName: column.name, 80 | rawValue: record.getValue(column.name)?.toString() as string || null, 81 | formattedValue: record.getFormattedValue(column.name), 82 | lookup: record.getValue(column.name) 83 | ? { 84 | name: record.getFormattedValue(column.name) ?? '(No Name)', 85 | // eslint-disable-next-line no-extra-parens 86 | key: (record.getValue(column.name) as ComponentFramework.EntityReference)?.id?.guid, 87 | } 88 | : undefined, 89 | type: column.dataType, 90 | })); 91 | 92 | return { 93 | key: record.getRecordId(), 94 | columns, 95 | }; 96 | }); 97 | -------------------------------------------------------------------------------- /EditableTable/services/DataverseService.ts: -------------------------------------------------------------------------------- 1 | import { IInputs } from '../generated/ManifestTypes'; 2 | import { IComboBoxOption, IDropdownOption, ITag } from '@fluentui/react'; 3 | import { getFetchResponse } from '../utils/fetchUtils'; 4 | import { Relationship } from '../store/features/LookupSlice'; 5 | import { Record } from '../store/features/RecordSlice'; 6 | import { DropdownField } from '../store/features/DropdownSlice'; 7 | import { NumberFieldMetadata } from '../store/features/NumberSlice'; 8 | import { NEW_RECORD_ID_LENGTH_CHECK } from '../utils/commonUtils'; 9 | 10 | export type ParentMetadata = { 11 | entityId: string, 12 | entityRecordName: string, 13 | entityTypeName: string, 14 | }; 15 | 16 | export type Entity = ComponentFramework.WebApi.Entity; 17 | 18 | export type EntityPrivileges = { 19 | create: boolean, 20 | read: boolean, 21 | write: boolean, 22 | delete: boolean, 23 | }; 24 | 25 | export type CurrencyData = { 26 | symbol: string, 27 | precision: number, 28 | } 29 | 30 | export interface ErrorDetails { 31 | code: number, 32 | errorCode: number, 33 | message: string, 34 | raw: string, 35 | title: string 36 | recordId?: string, 37 | } 38 | 39 | export interface IDataverseService { 40 | getEntityPluralName(entityName: string): Promise; 41 | getCurrentUserName(): string; 42 | getParentMetadata(): ParentMetadata; 43 | setParentValue(): Promise; 44 | openForm(id: string, entityName?: string): void; 45 | createNewRecord(data: {}): Promise; 46 | retrieveAllRecords(entityName: string, options: string): Promise; 47 | deleteRecord(recordId: string): Promise; 48 | openRecordDeleteDialog(): Promise; 49 | openErrorDialog(error: any): Promise; 50 | getFieldSchemaName(): Promise; 51 | parentFieldIsValid(record: Record, subgridParentFieldName: string | undefined): boolean; 52 | saveRecord(record: Record): Promise; 53 | getRelationships(): Promise; 54 | getLookupOptions(entityName: string): Promise; 55 | getDropdownOptions(fieldName: string, attributeType: string, isTwoOptions: boolean): 56 | Promise; 57 | getNumberFieldMetadata(fieldName: string, attributeType: string, selection: string): 58 | Promise; 59 | getCurrency(recordId: string): Promise; 60 | getCurrencyById(recordId: string): Promise; 61 | getTimeZoneDefinitions(): Promise; 62 | getProvisionedLanguages(): Promise; 63 | getDateMetadata(fieldName: string): Promise; 64 | getTextFieldMetadata(fieldName: string, type: string | undefined): Promise; 65 | getTargetEntityType(): string; 66 | getContext(): ComponentFramework.Context; 67 | getAllocatedWidth(): number; 68 | getReqirementLevel(fieldName: string): Promise; 69 | getSecurityPrivileges(): Promise; 70 | isStatusField(fieldName: string | undefined): boolean; 71 | isCalculatedField(fieldName: string | undefined): Promise; 72 | getGlobalPrecision(): Promise; 73 | getFirstDayOfWeek(): number; 74 | getWeekDayNamesShort(): string[]; 75 | getMonthNamesShort(): string[]; 76 | getMonthNamesLong(): string[]; 77 | getUserRelatedFieldServiceProfile(columnKey: string): 78 | Promise; 79 | isFieldSecured(columnName: string) : Promise; 80 | isRecordEditable(recordId: string): Promise; 81 | isOffline(): boolean; 82 | } 83 | 84 | export class DataverseService implements IDataverseService { 85 | private _context: ComponentFramework.Context; 86 | private _targetEntityType: string; 87 | private _clientUrl: string; 88 | private _parentValue: string | undefined; 89 | public _isOffline: boolean; 90 | 91 | constructor(context: ComponentFramework.Context) { 92 | this._context = context; 93 | this._targetEntityType = context.parameters.dataset.getTargetEntityType(); 94 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 95 | // @ts-ignore 96 | this._clientUrl = `${this._context.page.getClientUrl()}/api/data/v9.2/`; 97 | this._isOffline = this._context.client.isOffline(); 98 | } 99 | 100 | public getCurrentUserName() { 101 | return this._context.userSettings.userName; 102 | } 103 | 104 | public getParentMetadata() { 105 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 106 | // @ts-ignore 107 | return this._context.mode.contextInfo; 108 | } 109 | 110 | public async getEntityPluralName(entityName: string): Promise { 111 | const metadata = await this._context.utils.getEntityMetadata(entityName); 112 | return metadata.EntitySetName; 113 | } 114 | 115 | public async getParentPluralName(): Promise { 116 | const parentMetadata = this.getParentMetadata(); 117 | const parentEntityPluralName = await this.getEntityPluralName(parentMetadata.entityTypeName); 118 | return parentMetadata.entityId 119 | ? `/${parentEntityPluralName}(${parentMetadata.entityId})` 120 | : undefined; 121 | } 122 | 123 | public async setParentValue() { 124 | this._parentValue = await this.getParentPluralName(); 125 | } 126 | 127 | public openForm(id: string, entityName?: string) { 128 | const options = { 129 | entityId: id, 130 | entityName: entityName ?? this._targetEntityType, 131 | openInNewWindow: false, 132 | }; 133 | this._context.navigation.openForm(options); 134 | } 135 | 136 | public async createNewRecord(data: {}): Promise { 137 | return await this._context.webAPI.createRecord(this._targetEntityType, data); 138 | } 139 | 140 | public async retrieveAllRecords(entityName: string, options: string) { 141 | const entities = []; 142 | let result = await this._context.webAPI.retrieveMultipleRecords(entityName, options); 143 | entities.push(...result.entities); 144 | while (result.nextLink !== undefined) { 145 | options = result.nextLink.slice(result.nextLink.indexOf('?')); 146 | result = await this._context.webAPI.retrieveMultipleRecords(entityName, options); 147 | entities.push(...result.entities); 148 | } 149 | return entities; 150 | } 151 | 152 | public async deleteRecord(recordId: string): 153 | Promise { 154 | try { 155 | return await this._context.webAPI.deleteRecord(this._targetEntityType, recordId); 156 | } 157 | catch (error: any) { 158 | return { ...error, recordId }; 159 | } 160 | } 161 | 162 | public async openRecordDeleteDialog(): 163 | Promise { 164 | const entityMetadata = await this._context.utils.getEntityMetadata(this._targetEntityType); 165 | const strings = { 166 | text: `Do you want to delete selected ${entityMetadata._displayName}? 167 | You can't undo this action.`, 168 | title: 'Confirm Deletion', 169 | }; 170 | const options = { height: 200, width: 450 }; 171 | const response = await this._context.navigation.openConfirmDialog(strings, options); 172 | 173 | return response; 174 | } 175 | 176 | public openErrorDialog(error: any): Promise { 177 | const errorMessage = error.code === 2147746581 178 | ? 'You are missing some privileges, please contact your administrator' 179 | : error.message; 180 | 181 | const errorDialogOptions: ComponentFramework.NavigationApi.ErrorDialogOptions = { 182 | errorCode: error.code, 183 | message: errorMessage, 184 | details: error.raw, 185 | }; 186 | 187 | return this._context.navigation.openErrorDialog(errorDialogOptions); 188 | } 189 | 190 | public async getFieldSchemaName(): Promise { 191 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 192 | // @ts-ignore 193 | const logicalName = this._context.page.entityTypeName; 194 | const endpoint = `EntityDefinitions(LogicalName='${logicalName}')/OneToManyRelationships`; 195 | const options = `$filter=ReferencingEntity eq '${ 196 | this._targetEntityType}'&$select=ReferencingEntityNavigationPropertyName`; 197 | const request = `${this._clientUrl}${endpoint}?${options}`; 198 | const data = await getFetchResponse(request); 199 | return data.value[0]?.ReferencingEntityNavigationPropertyName; 200 | } 201 | 202 | public parentFieldIsValid(record: Record, subgridParentFieldName: string | undefined) { 203 | return subgridParentFieldName !== undefined && 204 | record.id.length < NEW_RECORD_ID_LENGTH_CHECK && 205 | !record.data.some(recordData => recordData.fieldName === subgridParentFieldName); 206 | } 207 | 208 | public async saveRecord(record: Record): 209 | Promise { 210 | const data = record.data.reduce((obj, recordData) => 211 | Object.assign(obj, 212 | recordData.fieldType === 'Lookup.Simple' 213 | ? { [`${recordData.fieldName}@odata.bind`]: recordData.newValue } 214 | : { [recordData.fieldName]: recordData.newValue }), {}); 215 | 216 | const subgridParentFieldName = await this.getFieldSchemaName(); 217 | if (this.parentFieldIsValid(record, subgridParentFieldName) && this._parentValue) { 218 | Object.assign(data, { [`${subgridParentFieldName}@odata.bind`]: this._parentValue }); 219 | } 220 | 221 | if (record.id.length < NEW_RECORD_ID_LENGTH_CHECK) { 222 | try { 223 | return await this.createNewRecord(data); 224 | } 225 | catch (error: any) { 226 | return error; 227 | } 228 | } 229 | else { 230 | try { 231 | return await this._context.webAPI.updateRecord(this._targetEntityType, record.id, data); 232 | } 233 | catch (error: any) { 234 | return { ...error, recordId: record.id }; 235 | } 236 | } 237 | } 238 | 239 | public async getRelationships(): Promise { 240 | const relationships = `ManyToManyRelationships,ManyToOneRelationships,OneToManyRelationships`; 241 | const request = `${this._clientUrl}EntityDefinitions(LogicalName='${ 242 | this._targetEntityType}')?$expand=${relationships}`; 243 | const results = await getFetchResponse(request); 244 | 245 | return [ 246 | ...results.OneToManyRelationships.map((relationship: any) => { 247 | fieldNameRef: relationship.ReferencingAttribute, 248 | entityNameRef: relationship.ReferencedEntity, 249 | entityNavigation: relationship.ReferencingEntityNavigationPropertyName, 250 | }, 251 | ), 252 | ...results.ManyToOneRelationships.map((relationship: any) => { 253 | fieldNameRef: relationship.ReferencingAttribute, 254 | entityNameRef: relationship.ReferencedEntity, 255 | entityNavigation: relationship.ReferencingEntityNavigationPropertyName, 256 | }, 257 | ), 258 | ...results.ManyToManyRelationships.map((relationship: any) => { 259 | fieldNameRef: relationship.ReferencingAttribute, 260 | entityNameRef: relationship.ReferencedEntity, 261 | }, 262 | ), 263 | ]; 264 | } 265 | 266 | public async getLookupOptions(entityName: string) { 267 | const metadata = await this._context.utils.getEntityMetadata(entityName); 268 | const entityNameFieldName = metadata.PrimaryNameAttribute; 269 | const entityIdFieldName = metadata.PrimaryIdAttribute; 270 | 271 | const fetchedOptions = await this.retrieveAllRecords(entityName, 272 | `?$select=${entityIdFieldName},${entityNameFieldName}`); 273 | 274 | const options: ITag[] = fetchedOptions.map(option => ({ 275 | key: option[entityIdFieldName], 276 | name: option[entityNameFieldName] ?? '(No Name)', 277 | })); 278 | 279 | return options; 280 | } 281 | 282 | public async getDropdownOptions(fieldName: string, attributeType: string, isTwoOptions: boolean) { 283 | const request = `${this._clientUrl}EntityDefinitions(LogicalName='${ 284 | this._targetEntityType}')/Attributes/Microsoft.Dynamics.CRM.${ 285 | attributeType}?$select=LogicalName&$filter=LogicalName eq '${fieldName}'&$expand=OptionSet`; 286 | let options: IDropdownOption[] = []; 287 | const results = await getFetchResponse(request); 288 | if (!isTwoOptions) { 289 | options = results.value[0].OptionSet.Options.map((result: any) => ({ 290 | key: result.Value.toString(), 291 | text: result.Label.UserLocalizedLabel.Label, 292 | })); 293 | } 294 | else { 295 | const trueKey = results.value[0].OptionSet.TrueOption.Value.toString(); 296 | const trueText = results.value[0].OptionSet.TrueOption.Label.UserLocalizedLabel.Label; 297 | options.push({ key: trueKey, text: trueText }); 298 | 299 | const falseKey = results.value[0].OptionSet.FalseOption.Value.toString(); 300 | const falseText = results.value[0].OptionSet.FalseOption.Label.UserLocalizedLabel.Label; 301 | options.push({ key: falseKey, text: falseText }); 302 | } 303 | return { fieldName, options }; 304 | } 305 | 306 | public async getNumberFieldMetadata(fieldName: string, attributeType: string, selection: string) { 307 | const request = `${this._clientUrl}EntityDefinitions(LogicalName='${ 308 | this._targetEntityType}')/Attributes/Microsoft.Dynamics.CRM.${attributeType}?$select=${ 309 | selection}&$filter=LogicalName eq '${fieldName}'`; 310 | const results = await getFetchResponse(request); 311 | 312 | let precision = results.value[0]?.PrecisionSource ?? results.value[0]?.Precision ?? 0; 313 | 314 | switch (precision) { 315 | case 0: 316 | precision = results.value[0]?.Precision; 317 | break; 318 | case 1: 319 | precision = this._isOffline ? results.value[0]?.Precision : await this.getGlobalPrecision(); 320 | break; 321 | default: 322 | precision; 323 | } 324 | 325 | return { 326 | fieldName, 327 | precision, 328 | minValue: results.value[0].MinValue, 329 | maxValue: results.value[0].MaxValue, 330 | isBaseCurrency: results.value[0].IsBaseCurrency, 331 | precisionNumber: results.value[0]?.Precision, 332 | }; 333 | } 334 | 335 | public async getGlobalPrecision() : Promise { 336 | const request = `${this._clientUrl}organizations?$select=pricingdecimalprecision`; 337 | const response = await getFetchResponse(request); 338 | return response?.value[0].pricingdecimalprecision; 339 | } 340 | 341 | public async getCurrency(recordId: string): Promise { 342 | const fetchedCurrency = await this._context.webAPI.retrieveRecord( 343 | this._targetEntityType, 344 | recordId, 345 | // eslint-disable-next-line max-len 346 | '?$select=_transactioncurrencyid_value&$expand=transactioncurrencyid($select=currencysymbol,currencyprecision)', 347 | ); 348 | return { 349 | symbol: fetchedCurrency.transactioncurrencyid?.currencysymbol ?? 350 | this._context.userSettings.numberFormattingInfo.currencySymbol, 351 | precision: fetchedCurrency.transactioncurrencyid?.currencyprecision ?? 352 | this._context.userSettings.numberFormattingInfo.currencyDecimalDigits }; 353 | } 354 | 355 | public async getCurrencyById(recordId: string): Promise { 356 | let fetchedCurrency = undefined; 357 | if (!this._isOffline) { 358 | fetchedCurrency = await this._context.webAPI.retrieveRecord( 359 | 'transactioncurrency', 360 | recordId, 361 | '?$select=currencysymbol,currencyprecision', 362 | ); 363 | } 364 | 365 | return { 366 | symbol: fetchedCurrency?.currencysymbol ?? 367 | this._context.userSettings.numberFormattingInfo.currencySymbol, 368 | precision: fetchedCurrency?.currencyprecision ?? 369 | this._context.userSettings.numberFormattingInfo.currencyDecimalDigits }; 370 | } 371 | 372 | public async getTimeZoneDefinitions() { 373 | const request = `${this._clientUrl}timezonedefinitions`; 374 | const results = await getFetchResponse(request); 375 | 376 | return results.value.sort((a: any, b: any) => b.bias - a.bias) 377 | .map((timezone: any) => { 378 | key: timezone.timezonecode.toString(), 379 | text: timezone.userinterfacename, 380 | }); 381 | } 382 | 383 | public async getProvisionedLanguages() { 384 | const request = `${this._clientUrl}RetrieveProvisionedLanguages`; 385 | const results = await getFetchResponse(request); 386 | 387 | return results.RetrieveProvisionedLanguages.map((language: any) => { 388 | key: language.toString(), 389 | text: this._context.formatting.formatLanguage(language), 390 | }); 391 | } 392 | 393 | public async getDateMetadata(fieldName: string) { 394 | const filter = `$filter=LogicalName eq '${fieldName}'`; 395 | const request = `${this._clientUrl}EntityDefinitions(LogicalName='${this._targetEntityType 396 | }')/Attributes/Microsoft.Dynamics.CRM.DateTimeAttributeMetadata?${filter}`; 397 | const results = await getFetchResponse(request); 398 | 399 | return results.value[0].DateTimeBehavior.Value; 400 | } 401 | 402 | public async getTextFieldMetadata(fieldName: string, type: string | undefined) { 403 | const filter = `$filter=LogicalName eq '${fieldName}'`; 404 | const attributeType = `${type === 'Multiple' 405 | ? 'MemoAttributeMetadata' : 'StringAttributeMetadata'}`; 406 | const request = `${this._clientUrl}EntityDefinitions(LogicalName='${this._targetEntityType 407 | }')/Attributes/Microsoft.Dynamics.CRM.${attributeType}?${filter}`; 408 | const results = await getFetchResponse(request); 409 | 410 | return results.value[0]?.MaxLength; 411 | } 412 | 413 | public getTargetEntityType() { 414 | return this._targetEntityType; 415 | } 416 | 417 | public getContext() { 418 | return this._context; 419 | } 420 | 421 | public getAllocatedWidth() { 422 | return this._context.mode.allocatedWidth; 423 | } 424 | 425 | public async getReqirementLevel(fieldName: string) { 426 | const request = `${this._clientUrl}EntityDefinitions(LogicalName='${ 427 | this._targetEntityType}')/Attributes(LogicalName='${fieldName}')?$select=RequiredLevel`; 428 | const results = await getFetchResponse(request); 429 | 430 | return results.RequiredLevel.Value; 431 | } 432 | 433 | public async getSecurityPrivileges() { 434 | const createPriv = this._context.utils.hasEntityPrivilege(this._targetEntityType, 1, 0); 435 | const readPriv = this._context.utils.hasEntityPrivilege(this._targetEntityType, 2, 0); 436 | const writePriv = this._context.utils.hasEntityPrivilege(this._targetEntityType, 3, 0); 437 | const deletePriv = this._context.utils.hasEntityPrivilege(this._targetEntityType, 4, 0); 438 | // doesnt look at the level (org vs user) 439 | return { 440 | create: createPriv, 441 | read: readPriv, 442 | write: writePriv, 443 | delete: deletePriv, 444 | }; 445 | } 446 | 447 | public async isCalculatedField(fieldName: string | undefined) { 448 | const request = `${this._clientUrl}EntityDefinitions(LogicalName='${ 449 | this._targetEntityType}')/Attributes(LogicalName='${fieldName}')?$select=IsValidForCreate`; 450 | const results = await getFetchResponse(request); 451 | 452 | return !results.IsValidForCreate; 453 | } 454 | 455 | public isStatusField(fieldName: string | undefined) { 456 | return fieldName === 'statuscode' || fieldName === 'statecode'; 457 | } 458 | 459 | public getFirstDayOfWeek() { 460 | return this._context.userSettings.dateFormattingInfo.firstDayOfWeek; 461 | } 462 | 463 | public getWeekDayNamesShort() { 464 | return this._context.userSettings.dateFormattingInfo.shortestDayNames; 465 | } 466 | 467 | public getMonthNamesShort() { 468 | return this._context.userSettings.dateFormattingInfo.abbreviatedMonthNames; 469 | } 470 | 471 | public getMonthNamesLong() { 472 | return this._context.userSettings.dateFormattingInfo.monthNames; 473 | } 474 | 475 | public async getUserRelatedFieldServiceProfile(columnName: string) : 476 | Promise { 477 | try { 478 | let fetchXml = `?fetchXml= 479 | 480 | 481 | 482 | 483 | 484 | 485 | 487 | 489 | 490 | 491 | 492 | 493 | 494 | 495 | 496 | 497 | `; 498 | 499 | let response = 500 | await this._context.webAPI.retrieveMultipleRecords('fieldpermission', fetchXml); 501 | 502 | if (response.entities.length === 0) { 503 | fetchXml = `?fetchXml= 504 | 505 | 506 | 507 | 508 | 509 | 510 | 512 | 514 | 515 | 516 | 517 | 518 | 520 | 521 | 522 | 523 | 524 | 525 | 526 | 527 | 528 | 529 | 530 | `; 531 | 532 | response = await this._context.webAPI.retrieveMultipleRecords('fieldpermission', fetchXml); 533 | } 534 | return response; 535 | } 536 | catch (error: any) { 537 | return null; 538 | } 539 | } 540 | 541 | public async isFieldSecured(columnName: string) : 542 | Promise { 543 | const request = `${this._clientUrl}EntityDefinitions(LogicalName='${ 544 | // eslint-disable-next-line max-len 545 | this._targetEntityType}')/Attributes?$select=IsSecured&$filter=LogicalName eq '${columnName}'`; 546 | const result = await getFetchResponse(request); 547 | return result.value[0].IsSecured; 548 | } 549 | 550 | public async isRecordEditable(recordId: string) { 551 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 552 | // @ts-ignore 553 | return this._context.parameters.dataset.records[recordId].isEditable(); 554 | } 555 | 556 | public isOffline(): boolean { 557 | return this._isOffline; 558 | } 559 | 560 | } 561 | -------------------------------------------------------------------------------- /EditableTable/store/features/DatasetSlice.ts: -------------------------------------------------------------------------------- 1 | import { createAsyncThunk, createSlice, 2 | isAnyOf, isPending, PayloadAction } from '@reduxjs/toolkit'; 3 | import { Row } from '../../mappers/dataSetMapper'; 4 | import { EntityPrivileges, IDataverseService } from '../../services/DataverseService'; 5 | 6 | export type RequirementLevel = { 7 | fieldName: string; 8 | isRequired: boolean; 9 | } 10 | 11 | export type CalculatedField = { 12 | fieldName: string; 13 | isCalculated: boolean; 14 | } 15 | 16 | export type Updates = { 17 | rowKey: string; 18 | columnName: string; 19 | newValue: any; 20 | } 21 | 22 | export type FieldSecurity = { 23 | fieldName: string; 24 | hasUpdateAccess: boolean; 25 | hasCreateAccess: boolean; 26 | } 27 | 28 | export type InactiveRecord = { 29 | recordId: string; 30 | isInactive: boolean; 31 | } 32 | 33 | export interface IDatasetState { 34 | rows: Row[], 35 | newRows: Row[], 36 | requirementLevels: RequirementLevel[], 37 | entityPrivileges: EntityPrivileges, 38 | calculatedFields: CalculatedField[], 39 | securedFields: FieldSecurity[], 40 | inactiveRecords: InactiveRecord[], 41 | isPending: boolean, 42 | } 43 | 44 | const initialState: IDatasetState = { 45 | rows: [], 46 | newRows: [], 47 | requirementLevels: [], 48 | entityPrivileges: {}, 49 | calculatedFields: [], 50 | securedFields: [], 51 | inactiveRecords: [], 52 | isPending: true, 53 | }; 54 | 55 | type DatasetPayload = { 56 | columnKeys: string[], 57 | _service: IDataverseService, 58 | } 59 | 60 | type RecordsPayload = { 61 | recordIds: string[], 62 | _service: IDataverseService, 63 | } 64 | 65 | export const setCalculatedFields = createAsyncThunk( 66 | 'dataset/setCalculatedFields', 67 | async payload => await Promise.all(payload.columnKeys.map(async columnKey => { 68 | const isCalculated = await payload._service.isCalculatedField(columnKey); 69 | return { fieldName: columnKey, isCalculated }; 70 | })), 71 | ); 72 | 73 | export const setRequirementLevels = createAsyncThunk( 74 | 'dataset/setRequirementLevels', 75 | async payload => await Promise.all(payload.columnKeys.map(async columnKey => { 76 | const isRequired = await payload._service.getReqirementLevel(columnKey) !== 'None'; 77 | return { fieldName: columnKey, isRequired }; 78 | })), 79 | ); 80 | 81 | export const setEntityPrivileges = createAsyncThunk( 82 | 'dataset/setEntityPrivileges', 83 | async _service => await _service.getSecurityPrivileges(), 84 | ); 85 | 86 | export const setSecuredFields = createAsyncThunk( 87 | 'dataset/setSecuredFields', 88 | async payload => await Promise.all(payload.columnKeys.map(async columnKey => { 89 | let hasUpdateAccess = true; 90 | let hasCreateAccess = true; 91 | 92 | const isFieldSecured = await payload._service.isFieldSecured(columnKey); 93 | if (!isFieldSecured) { 94 | return { fieldName: columnKey, hasUpdateAccess, hasCreateAccess }; 95 | } 96 | 97 | const fieldPermissionRecord = 98 | await payload._service.getUserRelatedFieldServiceProfile(columnKey); 99 | 100 | if (!fieldPermissionRecord) { 101 | return { fieldName: columnKey, hasUpdateAccess, hasCreateAccess }; 102 | } 103 | 104 | if (fieldPermissionRecord.entities.length > 0) { 105 | fieldPermissionRecord.entities.forEach(entity => { 106 | if (entity.canupdate === 0) { 107 | hasUpdateAccess = false; 108 | } 109 | 110 | if (entity.cancreate === 0) { 111 | hasCreateAccess = false; 112 | } 113 | }); 114 | 115 | return { fieldName: columnKey, hasUpdateAccess, hasCreateAccess }; 116 | } 117 | 118 | return { fieldName: columnKey, hasUpdateAccess: false, hasCreateAccess: false }; 119 | })), 120 | ); 121 | 122 | export const setInactiveRecords = createAsyncThunk( 123 | 'dataset/setInactiveRecords', 124 | async payload => await Promise.all(payload.recordIds.map(async recordId => { 125 | const isEditable = await payload._service.isRecordEditable(recordId); 126 | return { recordId, isInactive: !isEditable }; 127 | })), 128 | ); 129 | 130 | export const datasetSlice = createSlice({ 131 | name: 'dataset', 132 | initialState, 133 | reducers: { 134 | setRows: (state, action: PayloadAction) => { 135 | state.rows = action.payload; 136 | }, 137 | 138 | updateRow: (state, action: PayloadAction) => { 139 | const changedRow = state.rows.find(row => row.key === action.payload.rowKey); 140 | const changedColumn = changedRow!.columns 141 | .find(column => column.schemaName === action.payload.columnName); 142 | 143 | changedColumn!.rawValue = action.payload.newValue || undefined; 144 | changedColumn!.formattedValue = action.payload.newValue; 145 | changedColumn!.lookup = action.payload.newValue; 146 | }, 147 | 148 | addNewRow: (state, action: PayloadAction) => { 149 | state.rows.unshift(action.payload); 150 | }, 151 | 152 | readdNewRowsAfterDelete: (state, action: PayloadAction) => { 153 | state.newRows = action.payload; 154 | }, 155 | 156 | removeNewRows: state => { 157 | state.newRows = []; 158 | }, 159 | 160 | }, 161 | extraReducers: builder => { 162 | builder.addCase(setCalculatedFields.fulfilled, (state, action) => { 163 | state.calculatedFields = [...action.payload]; 164 | }); 165 | 166 | builder.addCase(setCalculatedFields.rejected, state => { 167 | state.calculatedFields = []; 168 | }); 169 | 170 | builder.addCase(setRequirementLevels.fulfilled, (state, action) => { 171 | state.requirementLevels = [...action.payload]; 172 | }); 173 | 174 | builder.addCase(setRequirementLevels.rejected, state => { 175 | state.requirementLevels = []; 176 | }); 177 | 178 | builder.addCase(setEntityPrivileges.fulfilled, (state, action) => { 179 | state.entityPrivileges = { ...action.payload }; 180 | }); 181 | 182 | builder.addCase(setEntityPrivileges.rejected, state => { 183 | state.entityPrivileges = {}; 184 | }); 185 | 186 | builder.addCase(setSecuredFields.fulfilled, (state, action) => { 187 | state.securedFields = [...action.payload]; 188 | }); 189 | 190 | builder.addCase(setSecuredFields.rejected, state => { 191 | state.securedFields = []; 192 | }); 193 | 194 | builder.addCase(setInactiveRecords.fulfilled, (state, action) => { 195 | state.inactiveRecords = [...action.payload]; 196 | }); 197 | 198 | builder.addMatcher(isAnyOf(isPending(setSecuredFields, setRequirementLevels, 199 | setCalculatedFields, setEntityPrivileges, setInactiveRecords)), state => { 200 | state.isPending = true; 201 | }); 202 | 203 | builder.addMatcher(isAnyOf(setSecuredFields.fulfilled, setSecuredFields.rejected), state => { 204 | state.isPending = false; 205 | }); 206 | }, 207 | }); 208 | 209 | export const { 210 | setRows, 211 | updateRow, 212 | addNewRow, 213 | readdNewRowsAfterDelete, 214 | removeNewRows, 215 | } = datasetSlice.actions; 216 | 217 | export default datasetSlice.reducer; 218 | -------------------------------------------------------------------------------- /EditableTable/store/features/DateSlice.ts: -------------------------------------------------------------------------------- 1 | import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; 2 | import { Field } from '../../hooks/useLoadStore'; 3 | import { IDataverseService } from '../../services/DataverseService'; 4 | 5 | export type DateMetadata = { 6 | fieldName: string, 7 | dateBehavior: string 8 | } 9 | 10 | export interface IDateState { 11 | dates: DateMetadata[] 12 | } 13 | 14 | const initialState: IDateState = { 15 | dates: [], 16 | }; 17 | 18 | type DateBehaviorPayload = { 19 | dateFields: Field[], 20 | _service: IDataverseService 21 | }; 22 | 23 | export const getDateBehavior = createAsyncThunk( 24 | 'date/getDateBehavior', 25 | async payload => 26 | await Promise.all(payload.dateFields.map(async date => { 27 | const behavior = await payload._service.getDateMetadata(date.key); 28 | 29 | return { 30 | fieldName: date.key, 31 | dateBehavior: behavior, 32 | }; 33 | })), 34 | ); 35 | 36 | export const dateSlice = createSlice({ 37 | name: 'date', 38 | initialState, 39 | reducers: {}, 40 | extraReducers: builder => { 41 | builder.addCase(getDateBehavior.fulfilled, (state, action) => { 42 | state.dates = [...action.payload]; 43 | }); 44 | 45 | builder.addCase(getDateBehavior.rejected, state => { 46 | state.dates.push({ fieldName: '', dateBehavior: '' }); 47 | }); 48 | }, 49 | }); 50 | 51 | export default dateSlice.reducer; 52 | -------------------------------------------------------------------------------- /EditableTable/store/features/DropdownSlice.ts: -------------------------------------------------------------------------------- 1 | import { IDropdownOption } from '@fluentui/react'; 2 | import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; 3 | import { Field } from '../../hooks/useLoadStore'; 4 | import { IDataverseService } from '../../services/DataverseService'; 5 | 6 | export type DropdownField = { 7 | fieldName: string, 8 | options: IDropdownOption[] 9 | } 10 | 11 | export interface IDropdownState { 12 | dropdownFields: DropdownField[] 13 | } 14 | 15 | const initialState: IDropdownState = { 16 | dropdownFields: [], 17 | }; 18 | 19 | export const getDropdownsOptions = 20 | createAsyncThunk( 21 | 'dropdown/getDropdownsOptions', 22 | async payload => 23 | await Promise.all(payload.dropdownFields.map(async dropdownField => { 24 | let attributeType: string; 25 | let isTwoOptions: boolean; 26 | 27 | switch (dropdownField.data) { 28 | case 'TwoOptions': 29 | attributeType = 'BooleanAttributeMetadata'; 30 | isTwoOptions = true; 31 | break; 32 | 33 | case 'MultiSelectPicklist': 34 | attributeType = 'MultiSelectPicklistAttributeMetadata'; 35 | isTwoOptions = false; 36 | break; 37 | 38 | default: 39 | attributeType = 'PicklistAttributeMetadata'; 40 | isTwoOptions = false; 41 | } 42 | 43 | switch (dropdownField.fieldName) { 44 | case 'statuscode': 45 | attributeType = 'StatusAttributeMetadata'; 46 | isTwoOptions = false; 47 | break; 48 | 49 | case 'statecode': 50 | attributeType = 'StateAttributeMetadata'; 51 | isTwoOptions = false; 52 | break; 53 | } 54 | 55 | const currentDropdown = await payload._service.getDropdownOptions( 56 | dropdownField.fieldName!, 57 | attributeType, 58 | isTwoOptions); 59 | return currentDropdown; 60 | })), 61 | ); 62 | 63 | const DropdownSlice = createSlice({ 64 | name: 'dropdown', 65 | initialState, 66 | reducers: {}, 67 | extraReducers: builder => { 68 | builder.addCase(getDropdownsOptions.fulfilled, (state, action) => { 69 | state.dropdownFields = [...action.payload]; 70 | }); 71 | 72 | builder.addCase(getDropdownsOptions.rejected, state => { 73 | state.dropdownFields = []; 74 | }); 75 | }, 76 | }); 77 | 78 | export default DropdownSlice.reducer; 79 | -------------------------------------------------------------------------------- /EditableTable/store/features/ErrorSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit'; 2 | 3 | export type InvalidField = { 4 | fieldId: string; 5 | isInvalid: boolean; 6 | errorMessage: string; 7 | }; 8 | 9 | export interface IErrorState { 10 | invalidFields: InvalidField[]; 11 | isInvalid: boolean; 12 | } 13 | 14 | const initialState: IErrorState = { 15 | invalidFields: [], 16 | isInvalid: false, 17 | }; 18 | 19 | export const ErrorSlice = createSlice({ 20 | name: 'error', 21 | initialState, 22 | reducers: { 23 | setInvalidFields: (state, action: PayloadAction) => { 24 | const { invalidFields } = state; 25 | const field = invalidFields.find(field => field.fieldId === action.payload.fieldId); 26 | 27 | if (field === undefined) { 28 | state.invalidFields.push(action.payload); 29 | } 30 | else { 31 | state.invalidFields = invalidFields.map(elem => { 32 | if (elem.fieldId === field.fieldId) { 33 | return action.payload; 34 | } 35 | return elem; 36 | }); 37 | } 38 | 39 | if (state.invalidFields.some(field => field.isInvalid)) { 40 | state.isInvalid = true; 41 | } 42 | else { 43 | state.isInvalid = false; 44 | } 45 | }, 46 | 47 | clearInvalidFields: state => { 48 | state.invalidFields = []; 49 | state.isInvalid = false; 50 | }, 51 | }, 52 | }); 53 | 54 | export const { setInvalidFields, clearInvalidFields } = ErrorSlice.actions; 55 | 56 | export default ErrorSlice.reducer; 57 | -------------------------------------------------------------------------------- /EditableTable/store/features/LoadingSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit'; 2 | 3 | export interface ILoadingState { 4 | isLoading: boolean 5 | } 6 | 7 | const initialState: ILoadingState = { 8 | isLoading: true, 9 | }; 10 | 11 | const LoadingSlice = createSlice({ 12 | name: 'loading', 13 | initialState, 14 | reducers: { 15 | setLoading: (state, action: PayloadAction) => { 16 | state.isLoading = action.payload; 17 | }, 18 | }, 19 | }); 20 | 21 | export const { setLoading } = LoadingSlice.actions; 22 | 23 | export default LoadingSlice.reducer; 24 | -------------------------------------------------------------------------------- /EditableTable/store/features/LookupSlice.ts: -------------------------------------------------------------------------------- 1 | import { ITag } from '@fluentui/react'; 2 | import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; 3 | import { Field } from '../../hooks/useLoadStore'; 4 | import { IDataverseService } from '../../services/DataverseService'; 5 | import { AsyncThunkConfig } from '../../utils/types'; 6 | 7 | export type Relationship = { 8 | fieldNameRef: string, 9 | entityNameRef: string, 10 | entityNavigation?: string 11 | } 12 | 13 | export type Lookup = { 14 | logicalName: string | undefined, 15 | reference: Relationship | undefined, 16 | entityPluralName: string | undefined, 17 | options: ITag[] 18 | } 19 | 20 | export interface ILookupState { 21 | relationships: Relationship[], 22 | lookups: Lookup[] 23 | } 24 | 25 | const initialState: ILookupState = { 26 | relationships: [], 27 | lookups: [], 28 | }; 29 | 30 | type LookupPayload = { 31 | lookupColumns: Field[], 32 | _service: IDataverseService 33 | }; 34 | 35 | export const setRelationships = createAsyncThunk( 36 | 'lookup/setRelationships', async _service => await _service.getRelationships(), 37 | ); 38 | 39 | export const setLookups = createAsyncThunk( 40 | 'lookup/setLookups', 41 | async (payload, thunkApi) => 42 | await Promise.all(payload.lookupColumns.map(async lookupColumn => { 43 | const { relationships } = thunkApi.getState().lookup; 44 | const { fieldName } = lookupColumn; 45 | 46 | const relationship: Relationship | undefined = 47 | relationships.find(relationship => { 48 | if (relationship.fieldNameRef === fieldName) return true; 49 | 50 | return false; 51 | }); 52 | 53 | const entityName = relationship?.entityNameRef ?? ''; 54 | const entityPluralName = await payload._service.getEntityPluralName(entityName); 55 | const options = await payload._service.getLookupOptions(entityName); 56 | 57 | return { 58 | logicalName: fieldName, 59 | reference: relationship, 60 | entityPluralName, 61 | options, 62 | }; 63 | })), 64 | ); 65 | 66 | export const LookupSlice = createSlice({ 67 | name: 'lookup', 68 | initialState, 69 | reducers: {}, 70 | extraReducers: builder => { 71 | builder.addCase(setRelationships.fulfilled, (state, action) => { 72 | state.relationships = [...action.payload]; 73 | }); 74 | 75 | builder.addCase(setRelationships.rejected, state => { 76 | state.relationships = []; 77 | }); 78 | 79 | builder.addCase(setLookups.fulfilled, (state, action) => { 80 | state.lookups = [...action.payload]; 81 | }); 82 | 83 | builder.addCase(setLookups.rejected, state => { 84 | state.lookups = []; 85 | }); 86 | }, 87 | }); 88 | 89 | export default LookupSlice.reducer; 90 | -------------------------------------------------------------------------------- /EditableTable/store/features/NumberSlice.ts: -------------------------------------------------------------------------------- 1 | import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; 2 | import { Field } from '../../hooks/useLoadStore'; 3 | import { IDataverseService } from '../../services/DataverseService'; 4 | 5 | export type NumberFieldMetadata = { 6 | fieldName: string, 7 | precision: number, 8 | minValue: number, 9 | maxValue: number, 10 | isBaseCurrency?: boolean, 11 | precisionNumber: number, 12 | } 13 | 14 | export type CurrencySymbol = { 15 | recordId: string, 16 | symbol: string, 17 | precision: number, 18 | } 19 | 20 | export interface INumberState { 21 | numberFieldsMetadata: NumberFieldMetadata[], 22 | currencySymbols: CurrencySymbol[] 23 | } 24 | 25 | const initialState: INumberState = { 26 | numberFieldsMetadata: [], 27 | currencySymbols: [], 28 | }; 29 | type NumberPayload = { 30 | numberFields: Field[], 31 | _service: IDataverseService, 32 | }; 33 | 34 | type CurrencyPayload = { recordIds: string[], _service: IDataverseService }; 35 | 36 | export const getNumberFieldsMetadata = createAsyncThunk( 37 | 'number/getNumberFieldsMetadata', 38 | async payload => 39 | await Promise.all(payload.numberFields.map(async numberField => { 40 | let attributeType, selection: string; 41 | 42 | switch (numberField.data) { 43 | case 'Currency': 44 | attributeType = 'MoneyAttributeMetadata'; 45 | selection = 'PrecisionSource,MaxValue,MinValue,IsBaseCurrency,Precision'; 46 | break; 47 | 48 | case 'Decimal': 49 | attributeType = 'DecimalAttributeMetadata'; 50 | selection = 'Precision,MaxValue,MinValue'; 51 | break; 52 | 53 | case 'FP': 54 | attributeType = 'DoubleAttributeMetadata'; 55 | selection = 'Precision,MaxValue,MinValue'; 56 | break; 57 | 58 | default: 59 | attributeType = 'IntegerAttributeMetadata'; 60 | selection = 'MaxValue,MinValue'; 61 | } 62 | 63 | const currentNumber = await payload._service.getNumberFieldMetadata( 64 | numberField.fieldName!, 65 | attributeType, 66 | selection); 67 | return currentNumber; 68 | })), 69 | ); 70 | 71 | export const getCurrencySymbols = createAsyncThunk( 72 | 'number/getCurrencySymbols', 73 | async payload => 74 | await Promise.all(payload.recordIds.map(async recordId => { 75 | const currencySymbol = await payload._service.getCurrency(recordId); 76 | return { 77 | recordId, 78 | symbol: currencySymbol.symbol, 79 | precision: currencySymbol.precision, 80 | }; 81 | })), 82 | ); 83 | 84 | const NumberSlice = createSlice({ 85 | name: 'number', 86 | initialState, 87 | reducers: {}, 88 | extraReducers(builder) { 89 | builder.addCase(getNumberFieldsMetadata.fulfilled, (state, action) => { 90 | state.numberFieldsMetadata = [...action.payload]; 91 | }); 92 | 93 | builder.addCase(getNumberFieldsMetadata.rejected, state => { 94 | state.numberFieldsMetadata = []; 95 | }); 96 | 97 | builder.addCase(getCurrencySymbols.fulfilled, (state, action) => { 98 | state.currencySymbols = [...action.payload]; 99 | }); 100 | 101 | builder.addCase(getCurrencySymbols.rejected, (state, action) => { 102 | console.log(action.error); 103 | state.currencySymbols = []; 104 | }); 105 | }, 106 | }); 107 | 108 | export default NumberSlice.reducer; 109 | -------------------------------------------------------------------------------- /EditableTable/store/features/RecordSlice.ts: -------------------------------------------------------------------------------- 1 | import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'; 2 | import { isNewRow, Row } from '../../mappers/dataSetMapper'; 3 | import { IDataverseService } from '../../services/DataverseService'; 4 | import { AsyncThunkConfig } from '../../utils/types'; 5 | import { RequirementLevel } from './DatasetSlice'; 6 | import { ErrorDetails } from '../../services/DataverseService'; 7 | import { getConsolidatedError, isError } from '../../utils/errorUtils'; 8 | import { NEW_RECORD_ID_LENGTH_CHECK } from '../../utils/commonUtils'; 9 | 10 | export type Record = { 11 | id: string; 12 | data: [ 13 | { 14 | fieldName: string, 15 | newValue: any, 16 | fieldType: string 17 | } 18 | ] 19 | }; 20 | 21 | export type RecordsAfterDelete = { 22 | newRows: Row[], 23 | changedRecordsAfterDelete: Record[] 24 | }; 25 | 26 | export interface IRecordState { 27 | changedRecords: Record[], 28 | changedRecordsAfterDelete: Record[], 29 | isPendingSave: boolean, 30 | isPendingDelete: boolean, 31 | } 32 | 33 | const initialState: IRecordState = { 34 | changedRecords: [], 35 | changedRecordsAfterDelete: [], 36 | isPendingSave: false, 37 | isPendingDelete: false, 38 | }; 39 | 40 | type DeleteRecordPayload = { 41 | recordIds: string[], 42 | _service: IDataverseService, 43 | }; 44 | 45 | const isRequiredFieldEmpty = 46 | (requirementLevels: RequirementLevel[], rows: Row[], _service: IDataverseService) => 47 | rows.some(row => 48 | row.columns.some(column => 49 | requirementLevels.find(requirementLevel => 50 | requirementLevel.fieldName === column.schemaName)?.isRequired && !column.rawValue && 51 | column.type !== 'Lookup.Customer' && column.type !== 'Lookup.Owner' && 52 | !_service.isStatusField(column.schemaName) && 53 | !(column.type === 'Currency' && column.schemaName.includes('base')), 54 | )); 55 | 56 | export const saveRecords = createAsyncThunk( 57 | 'record/saveRecords', 58 | async (_service, thunkApi) => { 59 | const { changedRecords } = thunkApi.getState().record; 60 | const { requirementLevels, rows } = thunkApi.getState().dataset; 61 | const { isInvalid } = thunkApi.getState().error; 62 | 63 | const changedRows = rows.filter( 64 | (row: Row) => changedRecords.some(changedRecord => changedRecord.id === row.key)); 65 | 66 | if (isInvalid) { 67 | return thunkApi.rejectWithValue({ 68 | message: 'Field validation errors must be fixed before saving.' }); 69 | } 70 | 71 | if (isRequiredFieldEmpty(requirementLevels, changedRows, _service)) { 72 | return thunkApi.rejectWithValue({ 73 | message: 'All required fields must be filled in before saving.' }); 74 | } 75 | _service.setParentValue(); 76 | 77 | const errors: ErrorDetails[] = []; 78 | await Promise.all(changedRecords.map(async record => { 79 | const response = await _service.saveRecord(record); 80 | if (isError(response)) errors.push(response); 81 | })); 82 | 83 | if (errors.length > 0) { 84 | if (changedRecords.length === 1) { 85 | _service.openErrorDialog(errors[0]); 86 | } 87 | else { 88 | const consolidatedError = getConsolidatedError(errors, 'saving'); 89 | _service.openErrorDialog(consolidatedError); 90 | } 91 | } 92 | }, 93 | ); 94 | 95 | export const deleteRecords = 96 | createAsyncThunk( 97 | 'record/deleteRecords', 98 | async (payload, thunkApi) => { 99 | const { changedRecords } = thunkApi.getState().record; 100 | const { rows } = thunkApi.getState().dataset; 101 | const recordsToRemove = new Set(payload.recordIds); 102 | const newRows = rows.filter(row => isNewRow(row) && !recordsToRemove.has(row.key)); 103 | 104 | const changedRecordsAfterDelete = changedRecords.filter(record => 105 | !recordsToRemove.has(record.id) && record.id.length < NEW_RECORD_ID_LENGTH_CHECK); 106 | 107 | const response = await payload._service.openRecordDeleteDialog(); 108 | if (response.confirmed) { 109 | const errors: ErrorDetails[] = []; 110 | await Promise.all(payload.recordIds.map(async id => { 111 | if (id.length > NEW_RECORD_ID_LENGTH_CHECK) { 112 | const response = await payload._service.deleteRecord(id); 113 | if (isError(response)) errors.push(response); 114 | } 115 | })); 116 | 117 | if (errors.length > 0) { 118 | if (payload.recordIds.length === 1) { 119 | payload._service.openErrorDialog(errors[0]); 120 | } 121 | else { 122 | const consolidatedError = getConsolidatedError(errors, 'deleting'); 123 | payload._service.openErrorDialog(consolidatedError); 124 | } 125 | } 126 | return thunkApi.fulfillWithValue({ newRows, changedRecordsAfterDelete }); 127 | } 128 | 129 | return thunkApi.rejectWithValue(undefined); 130 | }, 131 | ); 132 | 133 | const RecordSlice = createSlice({ 134 | name: 'record', 135 | initialState, 136 | reducers: { 137 | setChangedRecords: ( 138 | state, 139 | action: PayloadAction<{id: string, fieldName: string, fieldType: string, newValue: any}>) => { 140 | const { changedRecords } = state; 141 | const currentRecord = changedRecords?.find(record => record.id === action.payload.id); 142 | 143 | if (currentRecord === undefined) { 144 | changedRecords.push({ 145 | id: action.payload.id, 146 | data: [{ 147 | fieldName: action.payload.fieldName, 148 | newValue: action.payload.newValue, 149 | fieldType: action.payload.fieldType, 150 | }] }); 151 | } 152 | else { 153 | const currentField = currentRecord.data 154 | .find(data => data.fieldName === action.payload.fieldName); 155 | 156 | if (currentField === undefined) { 157 | currentRecord.data.push({ 158 | fieldName: action.payload.fieldName, 159 | newValue: action.payload.newValue, 160 | fieldType: action.payload.fieldType, 161 | }); 162 | } 163 | else { 164 | currentField.newValue = action.payload.newValue; 165 | currentField.fieldType = action.payload.fieldType; 166 | } 167 | } 168 | state.changedRecords = changedRecords; 169 | state.isPendingSave = true; 170 | }, 171 | 172 | readdChangedRecordsAfterDelete: state => { 173 | state.changedRecords = [...state.changedRecordsAfterDelete]; 174 | state.isPendingSave = !!(state.changedRecordsAfterDelete.length > 0); 175 | }, 176 | 177 | clearChangedRecords: state => { 178 | state.changedRecords = []; 179 | state.isPendingSave = false; 180 | }, 181 | 182 | clearChangedRecordsAfterRefresh: state => { 183 | state.changedRecordsAfterDelete = []; 184 | }, 185 | }, 186 | extraReducers(builder) { 187 | builder.addCase(saveRecords.fulfilled, state => { 188 | state.changedRecords = []; 189 | state.changedRecordsAfterDelete = []; 190 | state.isPendingSave = false; 191 | }); 192 | 193 | builder.addCase(deleteRecords.pending, state => { 194 | state.isPendingDelete = true; 195 | }); 196 | 197 | builder.addCase(deleteRecords.fulfilled, (state, action) => { 198 | state.changedRecords = action.payload.changedRecordsAfterDelete; 199 | state.changedRecordsAfterDelete = action.payload.changedRecordsAfterDelete; 200 | state.isPendingDelete = false; 201 | }); 202 | }, 203 | }); 204 | 205 | export const { 206 | setChangedRecords, 207 | clearChangedRecords, 208 | readdChangedRecordsAfterDelete, 209 | clearChangedRecordsAfterRefresh, 210 | } = RecordSlice.actions; 211 | 212 | export default RecordSlice.reducer; 213 | -------------------------------------------------------------------------------- /EditableTable/store/features/TextSlice.ts: -------------------------------------------------------------------------------- 1 | import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; 2 | import { Field } from '../../hooks/useLoadStore'; 3 | import { IDataverseService } from '../../services/DataverseService'; 4 | 5 | export type TextMetadata = { 6 | fieldName: string, 7 | textMaxLength: number 8 | } 9 | 10 | export interface ITextState { 11 | textFields: TextMetadata[] 12 | } 13 | 14 | const initialState: ITextState = { 15 | textFields: [], 16 | }; 17 | 18 | type TextMetadataPayload = { 19 | textFields: Field[], 20 | _service: IDataverseService 21 | }; 22 | 23 | export const getTextMetadata = createAsyncThunk( 24 | 'text/getTextMetadata', 25 | async payload => 26 | await Promise.all(payload.textFields.map(async textField => { 27 | const maxLength = await payload._service.getTextFieldMetadata(textField.key, textField.data); 28 | 29 | return { 30 | fieldName: textField.key, 31 | textMaxLength: maxLength, 32 | }; 33 | })), 34 | ); 35 | 36 | export const TextSlice = createSlice({ 37 | name: 'text', 38 | initialState, 39 | reducers: {}, 40 | extraReducers: builder => { 41 | builder.addCase(getTextMetadata.fulfilled, (state, action) => { 42 | state.textFields = [...action.payload]; 43 | }); 44 | 45 | builder.addCase(getTextMetadata.rejected, state => { 46 | state.textFields.push({ fieldName: '', textMaxLength: 100 }); 47 | }); 48 | }, 49 | }); 50 | 51 | export default TextSlice.reducer; 52 | -------------------------------------------------------------------------------- /EditableTable/store/features/WholeFormatSlice.ts: -------------------------------------------------------------------------------- 1 | import { IComboBoxOption } from '@fluentui/react'; 2 | import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; 3 | import { IDataverseService } from '../../services/DataverseService'; 4 | 5 | export interface IWholeFormatState { 6 | timezones: IComboBoxOption[]; 7 | languages: IComboBoxOption[] 8 | } 9 | 10 | const initialState: IWholeFormatState = { 11 | timezones: [], 12 | languages: [], 13 | }; 14 | 15 | export const getTimeZones = createAsyncThunk( 16 | 'wholeFormat/getTimeZones', 17 | async _service => { 18 | const timezones = await _service.getTimeZoneDefinitions(); 19 | return timezones; 20 | }, 21 | ); 22 | 23 | export const getLanguages = createAsyncThunk( 24 | 'wholeFormat/getLanguages', 25 | async _service => { 26 | const languages = await _service.getProvisionedLanguages(); 27 | return languages; 28 | }, 29 | ); 30 | 31 | const WholeFormatSlice = createSlice({ 32 | name: 'wholeFormat', 33 | initialState, 34 | reducers: { 35 | }, 36 | extraReducers(builder) { 37 | builder.addCase(getTimeZones.fulfilled, (state, action) => { 38 | state.timezones = [...action.payload]; 39 | }); 40 | 41 | builder.addCase(getLanguages.fulfilled, (state, action) => { 42 | state.languages = [...action.payload]; 43 | }); 44 | }, 45 | }); 46 | 47 | export default WholeFormatSlice.reducer; 48 | -------------------------------------------------------------------------------- /EditableTable/store/hooks.ts: -------------------------------------------------------------------------------- 1 | import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; 2 | import { AppDispatch, RootState } from '../utils/types'; 3 | 4 | export const useAppDispatch: () => AppDispatch = useDispatch; 5 | export const useAppSelector: TypedUseSelectorHook = useSelector; 6 | -------------------------------------------------------------------------------- /EditableTable/store/store.ts: -------------------------------------------------------------------------------- 1 | import { configureStore } from '@reduxjs/toolkit'; 2 | import lookupReducer from './features/LookupSlice'; 3 | import loadingReducer from './features/LoadingSlice'; 4 | import recordReducer from './features/RecordSlice'; 5 | import dropdownReducer from './features/DropdownSlice'; 6 | import numberReducer from './features/NumberSlice'; 7 | import wholeFormatReducer from './features/WholeFormatSlice'; 8 | import dateReducer from './features/DateSlice'; 9 | import datasetReducer from './features/DatasetSlice'; 10 | import errorReducer from './features/ErrorSlice'; 11 | import textReducer from './features/TextSlice'; 12 | 13 | export const callConfigureStore = () => configureStore({ 14 | reducer: { 15 | dataset: datasetReducer, 16 | lookup: lookupReducer, 17 | number: numberReducer, 18 | dropdown: dropdownReducer, 19 | loading: loadingReducer, 20 | record: recordReducer, 21 | wholeFormat: wholeFormatReducer, 22 | date: dateReducer, 23 | text: textReducer, 24 | error: errorReducer, 25 | }, 26 | }); 27 | -------------------------------------------------------------------------------- /EditableTable/styles/ButtonStyles.ts: -------------------------------------------------------------------------------- 1 | import { IButtonStyles, IIconProps } from '@fluentui/react'; 2 | import { mergeStyleSets } from '@fluentui/react/lib/Styling'; 3 | 4 | export const buttonStyles = mergeStyleSets({ 5 | commandBarButton: { 6 | root: { 7 | color: 'black', 8 | }, 9 | icon: { 10 | color: 'black', 11 | }, 12 | }, 13 | buttons: { 14 | height: '44px', 15 | display: 'flex', 16 | justifyContent: 'flex-end', 17 | marginTop: 20, 18 | position: 'sticky', 19 | top: '0', 20 | background: 'white', 21 | left: '0', 22 | zIndex: 3, 23 | }, 24 | }); 25 | 26 | export const commandBarButtonStyles: Partial = { 27 | root: { 28 | color: 'black', 29 | backgroundColor: 'white', 30 | }, 31 | rootHovered: { 32 | pointerEvents: 'cursor', 33 | }, 34 | icon: { 35 | color: 'black', 36 | }, 37 | }; 38 | 39 | export const deleteIcon: IIconProps = { iconName: 'Delete' }; 40 | export const refreshIcon: IIconProps = { iconName: 'Refresh' }; 41 | export const addIcon: IIconProps = { iconName: 'Add' }; 42 | export const saveIcon: IIconProps = { iconName: 'Save' }; 43 | -------------------------------------------------------------------------------- /EditableTable/styles/ComponentsStyles.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IBasePickerStyleProps, 3 | IBasePickerStyles, 4 | IButtonStyles, 5 | IComboBoxStyles, 6 | IDatePickerStyles, 7 | ISpinButtonStyles, 8 | IStackStyles, 9 | IStyleFunctionOrObject, 10 | ITextFieldStyles, 11 | mergeStyles, 12 | mergeStyleSets, 13 | } from '@fluentui/react'; 14 | 15 | export const textFieldStyles = (required: boolean): Partial => ({ 16 | root: { 17 | marginRight: required ? '10px' : '0px', 18 | }, 19 | }); 20 | 21 | export const datePickerStyles = (required: boolean): Partial => ({ 22 | root: { 23 | width: '-webkit-fill-available', 24 | }, 25 | wrapper: { 26 | marginRight: required ? '10px' : '0px', 27 | }, 28 | }); 29 | 30 | export const timePickerStyles = (required: boolean): Partial => ({ 31 | root: { 32 | display: 'inline-block', 33 | maxWidth: '150px', 34 | }, 35 | optionsContainer: { maxHeight: 260 }, 36 | container: { 37 | marginLeft: '-1px', 38 | maxWidth: 150, 39 | marginRight: required ? '10px' : '0px', 40 | }, 41 | }); 42 | 43 | export const optionSetStyles = (required: boolean): Partial => ({ 44 | container: { 45 | marginRight: required ? '10px' : '0px', 46 | }, 47 | }); 48 | 49 | export const stackComboBox : IStackStyles = { 50 | root: { 51 | flexFlow: 'row nowrap', 52 | maxWidth: 1000, 53 | }, 54 | }; 55 | 56 | export const lookupFormatStyles = (required: boolean, isDisabled: boolean): 57 | IStyleFunctionOrObject => ({ 58 | text: { 59 | minWidth: 30, 60 | overflow: 'hidden', 61 | outline: 'none', 62 | border: !isDisabled ? '1px solid black !important' : '', 63 | '::after': { 64 | border: isDisabled ? 'none !important' : '1px solid black', 65 | }, 66 | }, 67 | root: { 68 | minWidth: 30, 69 | overflow: 'hidden', 70 | marginRight: required ? '10px' : '0px', 71 | backgroundColor: 'white', 72 | }, 73 | input: { overflow: 'hidden' }, 74 | }); 75 | 76 | export const lookupSelectedOptionStyles: IButtonStyles = { 77 | root: { 78 | textAlign: 'left', 79 | padding: 0, 80 | fontSize: '13px', 81 | maxHeight: 30, 82 | border: 'none', 83 | }, 84 | splitButtonMenuButton: { 85 | borderTop: 'none', 86 | borderBottom: 'none', 87 | position: 'sticky', 88 | right: 0, 89 | background: 'white', 90 | zIndex: 3, 91 | cursor: 'pointer', 92 | '::before': { 93 | position: 'absolute', 94 | content: '', 95 | top: '10px', 96 | right: '20px', 97 | width: '1px', 98 | height: '5px', 99 | color: 'rgb(200, 198, 196)', 100 | }, 101 | }, 102 | splitButtonFlexContainer: { 103 | borderLeft: '1px solid rgb(200, 198, 196)', 104 | marginLeft: '-5px', 105 | marginRight: '-5px', 106 | }, 107 | label: { 108 | fontWeight: 400, 109 | }, 110 | }; 111 | 112 | export const numberFormatStyles = (required: boolean): Partial => ({ 113 | root: { 114 | minWidth: '20px', 115 | }, 116 | arrowButtonsContainer: { 117 | display: 'none', 118 | }, 119 | spinButtonWrapper: { 120 | marginRight: required ? '10px' : '0px', 121 | pointerEvents: 'all', 122 | minWidth: '20px', 123 | overflow: 'hidden', 124 | }, 125 | }); 126 | 127 | export const wholeFormatStyles = (required: boolean): Partial => ({ 128 | optionsContainer: { 129 | maxHeight: 260, 130 | }, 131 | container: { 132 | marginRight: required ? '10px' : '0px', 133 | }, 134 | }); 135 | 136 | export const loadingStyles = mergeStyleSets({ 137 | spinner: { 138 | height: 250, 139 | }, 140 | }); 141 | 142 | export const asteriskClassStyle = (required: boolean) => mergeStyles({ 143 | color: '#a4262c', 144 | position: 'absolute', 145 | top: '5px', 146 | right: '1px', 147 | fontSize: '5.5px', 148 | display: required ? 'flex' : 'none', 149 | }); 150 | 151 | export const error = (isInvalid: boolean, required: boolean) => mergeStyles({ 152 | display: isInvalid ? 'inline-block' : 'none', 153 | position: 'absolute', 154 | right: `${required ? '18px' : '8px'}`, 155 | top: '12px', 156 | fontSize: '16px', 157 | color: '#c0172b', 158 | cursor: 'pointer', 159 | }); 160 | -------------------------------------------------------------------------------- /EditableTable/styles/DatasetStyles.css: -------------------------------------------------------------------------------- 1 | .appWrapper { 2 | position: relative; 3 | z-index: 0; 4 | } 5 | 6 | .container { 7 | width: 100%; 8 | position: relative; 9 | color:black; 10 | flex-wrap: wrap; 11 | display: inline; 12 | } 13 | 14 | .ms-DetailsList { 15 | z-index: 2; 16 | } 17 | 18 | .ms-DetailsHeader-cell{ 19 | margin: 0px 4px; 20 | cursor: pointer; 21 | } 22 | 23 | .ms-DetailsRow-cell { 24 | padding: 0px; 25 | margin: 0px 4px; 26 | padding-top: 4px; 27 | overflow: visible; 28 | } 29 | 30 | .ms-DetailsRow-check { 31 | padding: 0px; 32 | margin-top: -4px; 33 | } 34 | 35 | @media screen and (max-width: 500px) { 36 | .ms-Button--commandBar{ 37 | font-size: 11px; 38 | } 39 | } 40 | 41 | @media screen and (max-width: 350px) { 42 | .ms-Button--commandBar{ 43 | font-size: 8px; 44 | } 45 | .ms-Button-textContainer{ 46 | font-size: 10px; 47 | } 48 | } 49 | 50 | .noDataContainer { 51 | position: absolute; 52 | top: 66px; 53 | width: -webkit-fill-available; 54 | height: -webkit-fill-available; 55 | justify-content: center; 56 | } 57 | 58 | .nodata { 59 | margin : 50px; 60 | font-size : 14px; 61 | text-align: center; 62 | } 63 | 64 | .loading { 65 | display: block; 66 | position: absolute; 67 | width: -webkit-fill-available; 68 | height: -webkit-fill-available; 69 | align-content: center; 70 | text-align: center; 71 | top: 0; 72 | padding: 106px 469px; 73 | background-color: white; 74 | opacity: 80%; 75 | z-index: 10; 76 | } 77 | 78 | .ms-DetailsHeader-cellName { 79 | font-size: 12px; 80 | } 81 | 82 | .errorDiv { 83 | position: absolute; 84 | right: 20px; 85 | top: 11px; 86 | width: 16px; 87 | height: 16px; 88 | } 89 | 90 | .errorDiv[title] { 91 | border-color: rgba(248, 58, 58, 0.326); 92 | } 93 | 94 | 95 | .ms-DetailsList-headerWrapper { 96 | display: inline; 97 | } 98 | .ms-BasePicker-text::after { 99 | border: none; 100 | border-right: 1px solid black; 101 | } 102 | 103 | .ms-BasePicker-text:active { 104 | border: 2px solid rgb(0, 120, 212) !important; 105 | } 106 | -------------------------------------------------------------------------------- /EditableTable/styles/DetailsListStyles.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IDetailsHeaderStyles, 3 | IDetailsListStyles, 4 | IDetailsRowStyles, 5 | } from '@fluentui/react'; 6 | import { IStackStyles } from '@fluentui/react/lib/components/Stack/Stack.types'; 7 | import { mergeStyleSets } from '@fluentui/react/lib/Styling'; 8 | 9 | export const stackStyles: Partial = { root: { height: 44, marginLeft: 100 } }; 10 | 11 | export const detailsHeaderStyles: Partial = mergeStyleSets({ 12 | root: { 13 | backgroundColor: 'white', 14 | fontSize: '12px', 15 | paddingTop: '0px', 16 | borderTop: '1px solid rgb(215, 215, 215)', 17 | position: 'sticky', 18 | top: '44px', 19 | zIndex: '3', 20 | }, 21 | }); 22 | 23 | export const detailsRowStyles: Partial = { 24 | root: { 25 | backgroundColor: 'white', 26 | fontSize: '14px', 27 | color: 'black', 28 | borderTop: '1px solid rgb(250, 250, 250)', 29 | borderBottom: '1px solid rgb(219 219 219)', 30 | }, 31 | }; 32 | 33 | export const gridStyles = (rowsLength: number): Partial => mergeStyleSets({ 34 | contentWrapper: { 35 | padding: rowsLength === 0 ? '50px' : '0', 36 | }, 37 | }); 38 | -------------------------------------------------------------------------------- /EditableTable/styles/FooterStyles.ts: -------------------------------------------------------------------------------- 1 | import { IButtonStyles } from '@fluentui/react/lib/components/Button/Button.types'; 2 | import { IIconProps } from '@fluentui/react/lib/components/Icon/Icon.types'; 3 | import { mergeStyleSets } from '@fluentui/react/lib/Styling'; 4 | 5 | export const PreviousIcon: IIconProps = { iconName: 'Previous' }; 6 | export const BackIcon: IIconProps = { iconName: 'Back' }; 7 | export const ForwardIcon: IIconProps = { iconName: 'Forward' }; 8 | 9 | export const footerStyles = mergeStyleSets({ 10 | content: { 11 | flex: '1 1 auto', 12 | display: 'flex', 13 | flexDirection: 'row', 14 | placeContent: 'stretch space-between', 15 | height: '40px', 16 | color: '#333', 17 | fontSize: '12px', 18 | alignItems: 'center', 19 | paddingLeft: '20px', 20 | paddingRight: '20px', 21 | position: 'sticky', 22 | bottom: '0', 23 | background: 'white', 24 | left: '0', 25 | zIndex: 3, 26 | }, 27 | }); 28 | 29 | export const footerButtonStyles: Partial = { 30 | root: { 31 | backgroundColor: 'transparent', 32 | cursor: 'pointer', 33 | height: '0px', 34 | color: 'green', 35 | }, 36 | icon: { 37 | fontSize: '12px', 38 | backgroundColor: 'transparent', 39 | cursor: 'pointer', 40 | height: '0px', 41 | color: 'rgb(0 120 212)', 42 | }, 43 | }; 44 | -------------------------------------------------------------------------------- /EditableTable/styles/RenderStyles.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { 3 | IDetailsListProps, 4 | CheckboxVisibility, 5 | DetailsHeader, 6 | } from '@fluentui/react'; 7 | import { detailsHeaderStyles } from './DetailsListStyles'; 8 | 9 | export const _onRenderDetailsHeader: IDetailsListProps['onRenderDetailsHeader'] = props => { 10 | if (props) { 11 | props.checkboxVisibility = CheckboxVisibility.always; 12 | return ; 13 | } 14 | return null; 15 | }; 16 | -------------------------------------------------------------------------------- /EditableTable/utils/commonUtils.ts: -------------------------------------------------------------------------------- 1 | export const NEW_RECORD_ID_LENGTH_CHECK = 15; 2 | 3 | export const getContainerHeight = (rowsLength: number) => { 4 | const height = rowsLength === 0 5 | ? 282 6 | : rowsLength < 10 7 | ? (rowsLength * 50) + 160 8 | : window.innerHeight - 280; 9 | return height; 10 | }; 11 | -------------------------------------------------------------------------------- /EditableTable/utils/dateTimeUtils.ts: -------------------------------------------------------------------------------- 1 | import { timesList } from '../components/InputComponents/timeList'; 2 | 3 | export const getDateFormatWithHyphen = (date: Date | undefined) => { 4 | if (date === undefined) return ''; 5 | 6 | const day = date.getDate() > 9 ? date.getDate() : `0${date.getDate()}`; 7 | const month = date.getMonth() + 1 > 9 ? `${date.getMonth() + 1}` : `0${date.getMonth() + 1}`; 8 | 9 | return `${date.getFullYear()}-${month}-${day}`; 10 | }; 11 | 12 | export const setTimeForDate = (value: Date | undefined, time: string | undefined) => { 13 | if (time === undefined || value === undefined) return value; 14 | 15 | const hours = time.split(':'); 16 | const newValue = value; 17 | newValue.setHours(parseFloat(hours[0]), parseFloat(hours[1])); 18 | 19 | return newValue; 20 | }; 21 | 22 | export const formatTimeto12Hours = (date: Date | undefined): string => { 23 | if (date === undefined) return ''; 24 | 25 | return date.toLocaleTimeString('en-US', { 26 | hour: 'numeric', 27 | minute: 'numeric', 28 | hour12: true, 29 | }); 30 | }; 31 | 32 | export const getTimeKeyFromDate = (date: Date) => { 33 | const hour = date.getHours() > 9 34 | ? date.getHours() 35 | : `0${date.getHours()}`; 36 | 37 | const minutes = date.getMinutes() > 9 38 | ? date.getMinutes() 39 | : `0${date.getMinutes()}`; 40 | 41 | const time = timesList.find(time => time.key === `${hour}:${minutes}`); 42 | return time === undefined ? `${hour}:${minutes}` : time.key; 43 | }; 44 | 45 | export const getTimeKeyFromTime = (value: string) => { 46 | let key = undefined; 47 | const timeRegex = /^(0?[1-9]|1[0-2]):[0-5]\d(?:\s|)(?:AM|PM)$/i; 48 | if (timeRegex.test(value.toLowerCase().toString())) { 49 | const splitKey = value.match(/[a-zA-Z]+|[0-9]+/g); 50 | if (splitKey !== null) { 51 | const hour = splitKey[0] === '12' ? 0 : parseFloat(splitKey[0]); 52 | if (splitKey[2].toLowerCase() === 'pm') { 53 | key = `${hour + 12}:${splitKey[1]}`; 54 | } 55 | else if (hour < 10) { 56 | key = `0${hour}:${splitKey[1]}`; 57 | } 58 | else { 59 | key = `${hour}:${splitKey[1]}`; 60 | } 61 | } 62 | } 63 | return key; 64 | }; 65 | -------------------------------------------------------------------------------- /EditableTable/utils/durationUtils.ts: -------------------------------------------------------------------------------- 1 | const getTextFromString = (value: string) => value.replace(/\d+/g, ''); 2 | 3 | const getNumberFromString = (value: string) => value.replace(/[^0-9.-]+/g, ''); 4 | 5 | export const getDurationOption = (value: string) => { 6 | let key: number | undefined; 7 | let optionText: string; 8 | 9 | const numberString = getNumberFromString(value); 10 | if (numberString) { 11 | const number = Number(numberString); 12 | const text = getTextFromString(value); 13 | if (text.includes('day')) { 14 | key = Math.round(number * 60 * 24); 15 | optionText = number > 1 ? `${number} days` : `${number} day`; 16 | } 17 | else if (text.includes('hour')) { 18 | key = Math.round(number * 60); 19 | optionText = number > 1 ? `${number} hours` : `${number} hour`; 20 | } 21 | else { 22 | key = Math.round(number); 23 | optionText = key > 1 ? `${key} minutes` : `${key} minute`; 24 | } 25 | return { text: optionText, key: key.toString(), hidden: true }; 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /EditableTable/utils/errorUtils.ts: -------------------------------------------------------------------------------- 1 | import { ErrorDetails } from '../services/DataverseService'; 2 | 3 | export const isError = (value: any): 4 | value is ErrorDetails => value !== undefined && value.code !== undefined; 5 | 6 | export const consolidateErrorMessages = (errors: ErrorDetails[]) => { 7 | let errorMsg = ''; 8 | errors.forEach(err => { 9 | const recordId = err.recordId ? `Record Id: ${err.recordId}` : ''; 10 | errorMsg += `\n ${recordId} \n ${err.raw} \n \n`; 11 | }); 12 | return errorMsg; 13 | }; 14 | 15 | export const getConsolidatedError = (errors: ErrorDetails[], type: string): ErrorDetails => ({ 16 | recordId: '', 17 | code: 0o0, 18 | errorCode: 0o0, 19 | message: `${errors.length} record(s) had errors when ${type}`, 20 | raw: consolidateErrorMessages(errors), 21 | title: `Multiple errors when ${type} records`, 22 | }); 23 | -------------------------------------------------------------------------------- /EditableTable/utils/fetchUtils.ts: -------------------------------------------------------------------------------- 1 | export const getFetchResponse = async (request: string) => { 2 | const response = await fetch(request); 3 | return await response.json(); 4 | }; 5 | -------------------------------------------------------------------------------- /EditableTable/utils/formattingUtils.ts: -------------------------------------------------------------------------------- 1 | import { IDropdownOption } from '@fluentui/react'; 2 | import { IDataverseService } from '../services/DataverseService'; 3 | 4 | export const formatNumber = (_service: IDataverseService, value: string) => 5 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 6 | // @ts-ignore 7 | Number.parseLocale(value.split(' ')[0], _service.getContext().client.locale); 8 | 9 | export const formatCurrency = 10 | (_service: IDataverseService, value: number, precisionSource?: number, symbol?: string) => 11 | _service.getContext().formatting.formatCurrency(value, precisionSource, symbol); 12 | 13 | export const formatDecimal = 14 | (_service: IDataverseService, value: number, precision?: number | undefined) => { 15 | if (value === null || value === undefined) return ''; 16 | return _service.getContext().formatting.formatDecimal(value, precision); 17 | }; 18 | 19 | export const formatDateShort = 20 | (_service: IDataverseService, value: Date, includeTime?: boolean): string => 21 | _service.getContext().formatting.formatDateShort(value, includeTime); 22 | 23 | export const formatUserDateTimeToUTC = 24 | (_service: IDataverseService, userDateTime: Date, behavior: 1 | 3 | 4): Date => 25 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 26 | // @ts-ignore 27 | new Date(_service.getContext().formatting.formatUserDateTimeToUTC(userDateTime, behavior)); 28 | 29 | export const formatUTCDateTimeToUserDate = 30 | (_service: IDataverseService, value: string): Date => 31 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 32 | // @ts-ignore 33 | _service.getContext().formatting.formatUTCDateTimeToUserDate(value); 34 | 35 | export const parseDateFromString = 36 | (_service: IDataverseService, value: string): Date => 37 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 38 | // @ts-ignore 39 | _service.getContext().formatting.parseDateFromString(value); 40 | -------------------------------------------------------------------------------- /EditableTable/utils/textUtils.ts: -------------------------------------------------------------------------------- 1 | export const isEmailValid = (value: string) => { 2 | const emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-]+$/i; 3 | return emailRegex.test(value); 4 | }; 5 | 6 | export const validateUrl = (value: string) => { 7 | if (value === '') return value; 8 | 9 | if (value.includes('http') && value.includes('://')) { 10 | return value; 11 | } 12 | return `https://${value}`; 13 | }; 14 | -------------------------------------------------------------------------------- /EditableTable/utils/types.ts: -------------------------------------------------------------------------------- 1 | import { AnyAction, ThunkMiddleware } from '@reduxjs/toolkit'; 2 | import { EnhancedStore } from '@reduxjs/toolkit/dist/configureStore'; 3 | import { EditableTable } from '..'; 4 | import { IDatasetState } from '../store/features/DatasetSlice'; 5 | import { IDateState } from '../store/features/DateSlice'; 6 | import { IDropdownState } from '../store/features/DropdownSlice'; 7 | import { ILoadingState } from '../store/features/LoadingSlice'; 8 | import { ILookupState } from '../store/features/LookupSlice'; 9 | import { INumberState } from '../store/features/NumberSlice'; 10 | import { IRecordState } from '../store/features/RecordSlice'; 11 | import { IWholeFormatState } from '../store/features/WholeFormatSlice'; 12 | import { IErrorState } from '../store/features/ErrorSlice'; 13 | import { ITextState } from '../store/features/TextSlice'; 14 | 15 | export interface StoreState { 16 | dataset: IDatasetState; 17 | lookup: ILookupState; 18 | number: INumberState; 19 | dropdown: IDropdownState; 20 | loading: ILoadingState; 21 | record: IRecordState; 22 | wholeFormat: IWholeFormatState; 23 | date: IDateState; 24 | text: ITextState; 25 | error: IErrorState; 26 | } 27 | 28 | export type Store = EnhancedStore< 29 | StoreState, 30 | AnyAction, 31 | [ThunkMiddleware]>; 32 | 33 | const table = new EditableTable(); 34 | 35 | export type RootState = ReturnType; 36 | export type AppDispatch = typeof table._store.dispatch; 37 | 38 | export type AsyncThunkConfig = { 39 | state: RootState, 40 | }; 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Bever 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MarketplaceAssets/images/primary.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BeverCRM/PCF-EditableTable/d9c6f72f54ca518fb79905ee3a9e116c1dd32df7/MarketplaceAssets/images/primary.png -------------------------------------------------------------------------------- /MarketplaceAssets/images/thumbnail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BeverCRM/PCF-EditableTable/d9c6f72f54ca518fb79905ee3a9e116c1dd32df7/MarketplaceAssets/images/thumbnail.png -------------------------------------------------------------------------------- /PCF-EditableTable.pcfproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | $(MSBuildExtensionsPath)\Microsoft\VisualStudio\v$(VisualStudioVersion)\PowerApps 5 | 6 | 7 | 8 | 9 | 10 | 11 | PCF-EditableTable 12 | 45109180-46e1-4226-8c33-5bfec971b40c 13 | $(MSBuildThisFileDirectory)out\controls 14 | 15 | 16 | 17 | v4.6.2 18 | 19 | net462 20 | PackageReference 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Editable Table 2 | 3 | This control converts the dataset into an editable table. 4 | 5 | ![EGtoET](https://user-images.githubusercontent.com/108401084/236837282-2d099412-0dc0-4301-92ba-a925812cfe4a.png) 6 | 7 | Control has the following functionalities: 8 | - **"+ New" button** - Adds a new row on top of the table where the user can fill in data and click "Save" to create the record. 9 | - **"Refresh" button** - Refreshes the table. 10 | - **"Save" button** - Creates records for new rows and updates records for changed existing rows. 11 | - **"Delete" button** - Deletes selected records. 12 | 13 | ![newButton](https://user-images.githubusercontent.com/108401084/236836468-57acaae7-fc5d-453d-b54b-6e5b089b5764.png) 14 | -------------------------------------------------------------------------------- /Solution/Solution.cdsproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | $(MSBuildExtensionsPath)\Microsoft\VisualStudio\v$(VisualStudioVersion)\PowerApps 5 | 6 | 7 | 8 | 9 | 10 | 11 | 3de81bcc-440f-4e02-b16f-c3b9488fe5bf 12 | v4.6.2 13 | 14 | net462 15 | PackageReference 16 | src 17 | 18 | 19 | 23 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | PreserveNewest 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /Solution/src/Other/Customizations.xml: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 1033 17 | 18 | -------------------------------------------------------------------------------- /Solution/src/Other/Relationships.xml: -------------------------------------------------------------------------------- 1 |  2 | -------------------------------------------------------------------------------- /Solution/src/Other/Solution.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Solution 6 | 7 | 8 | 9 | 10 | 11 | 1.0 12 | 13 | 2 14 | 15 | 16 | Bever 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | bvr 29 | 30 | 10031 31 | 32 | 33 |
34 | 1 35 | 1 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 1 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 |
61 |
62 | 2 63 | 1 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 1 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 |
89 |
90 |
91 | 92 | 93 |
94 |
-------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pcf-editable-table", 3 | "version": "1.0.0", 4 | "description": "Editable Grid with create, delete, save and refresh functionalities. New records are created as a new line in the grid.", 5 | "scripts": { 6 | "build": "pcf-scripts build", 7 | "clean": "pcf-scripts clean", 8 | "rebuild": "pcf-scripts rebuild", 9 | "start": "pcf-scripts start", 10 | "refreshTypes": "pcf-scripts refreshTypes" 11 | }, 12 | "dependencies": { 13 | "@fluentui/react": "^8.94.1", 14 | "@reduxjs/toolkit": "^1.9.0", 15 | "react": "^16.9.0", 16 | "react-dom": "^16.9.0", 17 | "react-redux": "^8.0.4" 18 | }, 19 | "devDependencies": { 20 | "@microsoft/eslint-plugin-power-apps": "^0.2.6", 21 | "@types/node": "^16.4.10", 22 | "@types/powerapps-component-framework": "^1.3.0", 23 | "@types/react": "^16.8", 24 | "@typescript-eslint/eslint-plugin": "^5.46.0", 25 | "@typescript-eslint/parser": "^5.46.0", 26 | "eslint": "^7.32.0", 27 | "eslint-config-bever": "^1.0.3", 28 | "eslint-plugin-import": "^2.27.5", 29 | "eslint-plugin-node": "^11.1.0", 30 | "eslint-plugin-promise": "^5.1.0", 31 | "eslint-plugin-react": "^7.29.3", 32 | "pcf-scripts": "^1", 33 | "pcf-start": "^1", 34 | "typescript": "^4.3" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /pcfconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "outDir": "./out/controls" 3 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./node_modules/pcf-scripts/tsconfig_base.json", 3 | "compilerOptions": { 4 | "typeRoots": ["node_modules/@types"], 5 | "esModuleInterop": true 6 | } 7 | } --------------------------------------------------------------------------------