diff --git a/package-lock.json b/package-lock.json index 29f9271f..e96e0fcd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,8 +22,8 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@fluentui/react-migration-v8-v9": "^9.6.20", - "@talxis/client-libraries": "1.2605.1", - "@talxis/client-metadata": "^0.0.3", + "@talxis/client-libraries": "1.2606.1", + "@talxis/client-metadata": "^0.0.4", "color": "^4.2.3", "dayjs": "^1.11.10", "debounce": "^2.2.0", @@ -31,6 +31,7 @@ "fast-deep-equal": "^3.1.3", "hotkeys-js": "^3.13.15", "humanize-duration": "^3.32.0", + "lexorank": "^1.0.5", "liquidjs": "^9.43.0", "merge-anything": "^5.1.7", "numeral": "^2.0.6", @@ -2757,9 +2758,9 @@ } }, "node_modules/@talxis/client-libraries": { - "version": "1.2605.1", - "resolved": "https://registry.npmjs.org/@talxis/client-libraries/-/client-libraries-1.2605.1.tgz", - "integrity": "sha512-51+6HbStfwJt0EpoWIXdDefriSIJBa28beem+aQwUUzcBVfJ+xl56quISfob67gaUrsDerIM7OX01sOh5GNIkQ==", + "version": "1.2606.1", + "resolved": "https://registry.npmjs.org/@talxis/client-libraries/-/client-libraries-1.2606.1.tgz", + "integrity": "sha512-VdSfczZqWAVSOHhTb2ZYAEV07XwGPq1AzwvR2NxmZ6Aq7+h+lHHLxyvLKY0wWlDZECJf+TbXNsQHRURFhT0HTQ==", "license": "MIT", "dependencies": { "@azure/msal-browser": "^2.27.0", @@ -2777,12 +2778,18 @@ "validator": "^13.12.0" } }, - "node_modules/@talxis/client-metadata": { + "node_modules/@talxis/client-libraries/node_modules/@talxis/client-metadata": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/@talxis/client-metadata/-/client-metadata-0.0.3.tgz", "integrity": "sha512-pbAERQAwPzHlxLt+1y7jKCmZWLvu495xOipgF6y+cKBP+WMMedvLEpr7fd5UGf6AmiJkLreFQL6OJh8P2zz+8w==", "license": "MIT" }, + "node_modules/@talxis/client-metadata": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@talxis/client-metadata/-/client-metadata-0.0.4.tgz", + "integrity": "sha512-A/gi83MOS4csJm4FEfrQZV8436sEvx+QunZMiyvxSRtRp8JuntUJG4RhXh/hgoaTX+r0yJflhRyDiyWrohUkmw==", + "license": "MIT" + }, "node_modules/@talxis/react-components": { "version": "1.2505.2", "resolved": "https://registry.npmjs.org/@talxis/react-components/-/react-components-1.2505.2.tgz", @@ -5988,6 +5995,12 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/lexorank": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/lexorank/-/lexorank-1.0.5.tgz", + "integrity": "sha512-K1B/Yr/gIU0wm68hk/yB0p/mv6xM3ShD5aci42vOwcjof8slG8Kpo3Q7+1WTv7DaRHKWRgLPqrFDt+4GtuFAtA==", + "license": "MIT" + }, "node_modules/lie": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", @@ -8468,6 +8481,7 @@ "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", "license": "MIT", "bin": { "uuid": "dist/bin/uuid" diff --git a/package.json b/package.json index 419bb8d2..b35c44d3 100644 --- a/package.json +++ b/package.json @@ -33,12 +33,12 @@ "@types/powerapps-component-framework": "^1.3.14", "@types/react": "17.0.90", "@types/react-dom": "^17.0.21", + "@types/react-window": "1.8.8", "@types/validator": "^13.11.9", "@types/xrm": "^9.0.81", "babel-loader": "^8.2.3", "esbuild": "^0.13.9", "glob": "^11.0.0", - "@types/react-window": "1.8.8", "postcss": "^8.3.11", "rollup": "^2.58.1", "rollup-plugin-delete": "^2.0.0", @@ -84,8 +84,8 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@fluentui/react-migration-v8-v9": "^9.6.20", - "@talxis/client-libraries": "1.2605.1", - "@talxis/client-metadata": "^0.0.3", + "@talxis/client-libraries": "1.2606.1", + "@talxis/client-metadata": "^0.0.4", "color": "^4.2.3", "dayjs": "^1.11.10", "debounce": "^2.2.0", @@ -93,6 +93,7 @@ "fast-deep-equal": "^3.1.3", "hotkeys-js": "^3.13.15", "humanize-duration": "^3.32.0", + "lexorank": "^1.0.5", "liquidjs": "^9.43.0", "merge-anything": "^5.1.7", "numeral": "^2.0.6", @@ -107,4 +108,4 @@ "react": "^16.8.6 || ^17.0.2", "react-dom": "^16.8.6 || ^17.0.2" } -} \ No newline at end of file +} diff --git a/rollup.config.js b/rollup.config.js index 620360da..82996185 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -51,7 +51,8 @@ const externalDeps = [ 'react-select', 'react-select/async', 'hotkeys-js', - 'react-window' + 'react-window', + "lexorank" ] export default [ diff --git a/src/components/DatasetControl/EditColumns/OptionText/OptionText.tsx b/src/components/DatasetControl/EditColumns/OptionText/OptionText.tsx index c02b960d..5fa02fff 100644 --- a/src/components/DatasetControl/EditColumns/OptionText/OptionText.tsx +++ b/src/components/DatasetControl/EditColumns/OptionText/OptionText.tsx @@ -2,10 +2,8 @@ import { IColumn } from "@talxis/client-libraries"; import { useMemo } from "react"; import { GroupBase, OptionProps } from "react-select"; import { getOptionTextStyles } from "./styles"; -import { Text } from "@fluentui/react"; export const OptionText = (props: React.PropsWithChildren>>) => { const styles = useMemo(() => getOptionTextStyles(), []); - - return {props.children} + return {props.children} } \ No newline at end of file diff --git a/src/components/Grid/cells/cell/Cell.tsx b/src/components/Grid/cells/cell/Cell.tsx index 1ff6dbb0..35864a89 100644 --- a/src/components/Grid/cells/cell/Cell.tsx +++ b/src/components/Grid/cells/cell/Cell.tsx @@ -71,10 +71,10 @@ export const Cell = (props: ICellProps) => { const onCellClick = useCallback((e: MouseEvent) => { if (record.getDataProvider().getSummarizationType() === 'grouping' && !grid.isSelectionModifierKeyPressed()) { - e.stopPropagation(); + //e.stopPropagation(); } else if (node.isSelected()) { - e.stopPropagation(); + //e.stopPropagation(); } }, []); diff --git a/src/components/Grid/column-headers/column-header/ColumnHeader.tsx b/src/components/Grid/column-headers/column-header/ColumnHeader.tsx index 7c76c453..6c1bba46 100644 --- a/src/components/Grid/column-headers/column-header/ColumnHeader.tsx +++ b/src/components/Grid/column-headers/column-header/ColumnHeader.tsx @@ -27,7 +27,7 @@ export const ColumnHeader = (props: IColumnHeader) => { //needs to be called with onTouchEnd as well since ag grid cancels the click event on them const onClick = () => { - if ((!column.isFilterable && column.disableSorting && !column.canBeAggregated && !column?.metadata?.CanBeGrouped)) { + if ((!column.isFilterable && column.disableSorting && !column.canBeAggregated && !column.canBeGrouped)) { return; } setColumnHeaderContextualMenuProps({ diff --git a/src/components/Grid/grid/ag-grid/AgGridModel.ts b/src/components/Grid/grid/ag-grid/AgGridModel.ts index d721031a..522d7cd9 100644 --- a/src/components/Grid/grid/ag-grid/AgGridModel.ts +++ b/src/components/Grid/grid/ag-grid/AgGridModel.ts @@ -665,7 +665,7 @@ export class AgGridModel extends EventEmitter { const parameters = columnInfo.ui.getControlParameters({ ...this._grid.getFieldBindingParameters(record, column, editing), ...control.getParameters(), - }) + }); if (column.oneClickEdit && record.getSummarizationType() === 'none') { editing = true; } @@ -679,7 +679,7 @@ export class AgGridModel extends EventEmitter { aggregatedValue: value.aggregatedValue, loading: columnInfo.ui.isLoading(), errorMessage: columnInfo.errorMessage, - editable: columnInfo.security.editable, + editable: column.isEditable && columnInfo.security.editable, editing: editing, parameters: parameters, saving: record.isSaving(), diff --git a/src/components/Lookup/Lookup.tsx b/src/components/Lookup/Lookup.tsx index 13c3241d..04ecabbe 100644 --- a/src/components/Lookup/Lookup.tsx +++ b/src/components/Lookup/Lookup.tsx @@ -105,6 +105,10 @@ export const Lookup = (props: ILookup) => { return mouseOver || isFocused; } + const getDisplayName = () => { + return props.parameters.value.attributes.DisplayName ?? labels.record(); + } + const getSecondaryName = async (result: ComponentFramework.LookupValue & { entityData: { [key: string]: any; @@ -195,7 +199,7 @@ export const Lookup = (props: ILookup) => { if (context.mode.isControlDisabled) { return; } - setPlaceholder(`${labels.placeholder()} ${props.parameters.value.attributes.DisplayName}`); + setPlaceholder(`${labels.placeholder()} ${getDisplayName()}`); }, onMouseLeave: () => { setPlaceholder("---"); diff --git a/src/components/Lookup/translations.ts b/src/components/Lookup/translations.ts index dbc3cbe1..862c9f14 100644 --- a/src/components/Lookup/translations.ts +++ b/src/components/Lookup/translations.ts @@ -27,6 +27,10 @@ export const lookupTranslations = { 1033: "Search", 1029: "Vyhledejte" }, + record: { + 1033: "Record", + 1029: "Záznam" + }, all: { 1033: "All", 1029: "Vše" diff --git a/src/components/TaskGrid/README.md b/src/components/TaskGrid/README.md index 2bbe505c..bbe8ec9d 100644 --- a/src/components/TaskGrid/README.md +++ b/src/components/TaskGrid/README.md @@ -2,19 +2,20 @@ > **Note:** For a minimal PCF wrapper and integration example, see [demo PCF](https://github.com/brYch97/task-pcf). -A hierarchical task management grid built on [AG Grid](https://www.ag-grid.com/). It renders tasks in a parent-child tree structure with support for drag-and-drop reordering, inline editing, saved views, custom columns, and template-based task creation. + +A hierarchical task-management grid built on [AG Grid](https://www.ag-grid.com/). It renders tasks in a parent–child tree structure and supports drag-and-drop reordering, inline editing, saved views, custom columns, and template-based task creation. + +The control is headless by design: all data access and business logic is supplied by you through a **descriptor** and a set of **strategies**. A ready-made Dataverse implementation is included in `extensions/dataverse`, but you can create your own strategies to connect the grid to any data source. --- ## Usage -Import and render the `` React component, passing it a `pcfContext` and your `ITaskGridDescriptor` implementation: - ```tsx import { TaskGrid } from '@talxis/base-controls'; -import { MyDescriptor } from './MyDescriptor'; +import { DataverseTaskGridDescriptor } from '@talxis/base-controls/dist/components/TaskGrid/extensions/dataverse'; -const descriptor = new MyDescriptor(); +const descriptor = new DataverseTaskGridDescriptor({ /* see Dataverse strategy section */ }); export const MyTaskGridPage = ({ pcfContext }) => ( ( | Prop | Required | Description | |------|:--------:|-------------| -| `pcfContext` | ✅ | The PCF `ComponentFramework.Context` instance. Used for navigation, error dialogs, and environment utilities. | -| `taskGridDescriptor` | ✅ | Your implementation of `ITaskGridDescriptor`. The single configuration entry point for all business logic. | -| `labels?` | — | Partial `ITaskGridLabels` override. Any key you supply replaces the English default for that label. | -| `components?` | — | Partial `ITaskGridComponents` override. Allows replacing the skeleton loader (`onRenderSkeleton`) or the command bar (`onRenderCommandBar`). | - -The component manages its own `ITaskGridState` (active view, filters, sorting, column widths, flat-list toggle) internally using a React ref. State survives remounts triggered by view changes. +| `pcfContext` | ✅ | A `ComponentFramework.Context` instance. Used for navigation, error dialogs and environment utilities. | +| `taskGridDescriptor` | ✅ | Your `ITaskGridDescriptor` implementation. The single entry point for all business logic and configuration. | +| `labels?` | — | Partial `ITaskGridLabels` map. Any key you supply replaces the English default for that label. | +| `components?` | — | Partial `ITaskGridComponents` map. Lets you replace the skeleton loader or the command bar. | --- -## The Descriptor +## The Descriptor (`ITaskGridDescriptor`) -The descriptor is where you wire all business logic into the grid. Create a class that implements `ITaskGridDescriptor`. +The descriptor wires your data and configuration into the grid. Create a class that implements `ITaskGridDescriptor`. -### Minimal example - -```ts -import { - ITaskGridDescriptor, - INativeColumns, - ITaskStrategyDeps, -} from '@talxis/base-controls'; -import { ISavedQueryStrategy, ISavedQuery } from '@talxis/base-controls/dist/components/TaskGrid/data-providers'; -import { MyTaskStrategy } from './MyTaskStrategy'; +### Interface -export class MyDescriptor implements ITaskGridDescriptor { - - public onGetNativeColumns(): INativeColumns { - return { - subject: 'subject', - parentId: 'my_parenttaskid', - stackRank: 'my_stackrank', - stateCode: 'statecode', - path: 'talxis_path', // virtual — computed by the grid - percentComplete: 'percentcomplete', - }; - } +| Method | Required | Description | +|--------|:--------:|-------------| +| `onGetFieldMapping()` | ✅ | Maps logical column roles to physical attribute names in your schema. | +| `onCreateSavedQueryStrategy()` | ✅ | Returns the strategy that loads and persists saved views. | +| `onCreateTaskStrategy(deps)` | ✅ | Returns the strategy handling all task CRUD, move and template operations. | +| `onCreateUserQueryDataProvider()` | ✅ | Returns an `IDataProvider` that backs the save-view dialog. | +| `onGetHeight?()` | — | Returns the container height as a CSS string. Falls back to filling the parent when omitted. | +| `onCreateCustomColumnsStrategy?()` | — | Enables user-defined columns. Return a `ICustomColumnsStrategy` implementation. | +| `onCreateTemplateDataProvider?()` | — | Enables template-based task creation. Return an `IDataProvider` whose records represent templates. | +| `onCreateGridCustomizerStrategy?()` | — | Deep-customizes AG Grid column definitions, cell renderers, editors and row class rules. | +| `onGetControlId?()` | — | Returns a stable DOM identifier. Auto-generated as a UUID when omitted. | +| `onLoadDependencies?()` | — | Async hook called once before any provider is created. Use for pre-loading or authentication. | +| `onGetGridParameters?()` | — | Returns `ITaskGridParameters` UI feature flags. All flags default to `false` when omitted. | - public onCreateTaskStrategy(deps: ITaskStrategyDeps) { - return new MyTaskStrategy(deps); - } +### Example - public onCreateSavedQueryStrategy(): ISavedQueryStrategy { - return { - onGetSystemQueries: async (): Promise => [ - { - id: '00000000-0000-0000-0000-000000000000', - name: 'All Tasks', - isFlatListEnabled: false, - columns: MY_DEFAULT_COLUMNS, - }, - ], - onEnableUserQueries: () => true, - onGetUserQueries: async () => [], - onCreateUserQuery: async () => null, - onUpdateUserQuery: async () => null, - onDeleteUserQueries: async () => ({ success: true, deletedQueryIds: [] }), - }; - } +The quickest way to get started is to use the built-in `DataverseTaskGridDescriptor` from `extensions/dataverse`, which handles all Dataverse wiring for you: - public onCreateUserQueryDataProvider() { - return new MyUserQueryProvider(); - } -} +```ts +import { TaskGrid } from '@talxis/base-controls'; +import { DataverseTaskGridDescriptor } from '@talxis/base-controls/dist/components/TaskGrid/extensions/dataverse'; + +const descriptor = new DataverseTaskGridDescriptor({ + height: '600px', + onInitialize: async () => ({ + baseFetchXml: ` + + + {% if projectId %} + + + + {% endif %} + + `, + fieldMapping: { + subject: 'talxis_name', + parentId: 'talxis_parentprojecttaskid', + stackRank: 'talxis_stackrankstring', + projectId: 'talxis_projectid', + }, + systemQueries: [ + { + id: '00000000-0000-0000-0000-000000000001', + name: 'All Tasks', + columns: [ + { name: 'talxis_name' }, + { name: 'statecode' }, + { name: 'talxis_stackrankstring', isHidden: true }, + { name: 'talxis_parentprojecttaskid', isHidden: true }, + ], + sorting: [{ name: 'talxis_stackrankstring', sortDirection: 0 }], + }, + ], + projectRecord: { entityName: 'talxis_project', id: projectId }, + userId: currentUserId, + createFormId: '', + editFormId: '', + gridParameters: { enableInlineCreation: true }, + }), +}); + + ``` -### Full example — all optional features (in-memory) - -The `MemoryDescriptor` pattern shows every optional hook in action using in-memory data, making it ideal as a reference or for testing: +See the [Dataverse strategy](#dataverse-strategy-pre-made) section for the full `DataverseTaskGridDescriptor` params reference and how to extend it. -```ts -export class MemoryDescriptor implements ITaskGridDescriptor { - - private _userQueries: ISavedQuery[] = [ - { - id: 'uq-default-01-0000-0000-000000000000', - name: 'My Open Tasks', - isFlatListEnabled: false, - columns: COLUMNS.filter(c => - c.isHidden || - ['subject', 'statuscode', 'priority', 'scheduledend', 'assignedto'].includes(c.name) - ), - filtering: { - filterOperator: 1, // And - conditions: [{ attributeName: 'statecode', conditionOperator: 0, value: '0' }], - }, - }, - ]; +--- - public onGetNativeColumns(): INativeColumns { - return { - subject: SUBJECT_COL, parentId: PARENT_ID_COL, stackRank: STACK_RANK_COL, - path: PATH_COL, stateCode: STATE_CODE_COL, percentComplete: PERCENT_COMPLETE_COL, - }; - } +## `IFieldMapping` - public onCreateTaskStrategy(deps: ITaskStrategyDeps) { - // deps.templateDataProvider is non-null because onCreateTemplateDataProvider is defined. - return new MemoryTaskStrategy(deps.templateDataProvider); - } +Maps logical roles to physical attribute names in your entity schema. - public onCreateSavedQueryStrategy(): ISavedQueryStrategy { - return { - onGetSystemQueries: async () => [{ - id: '00000000-0000-0000-0000-000000000000', - name: 'All Tasks', - isFlatListEnabled: false, - columns: COLUMNS.filter(c => - c.isHidden || ['subject', 'statuscode', 'priority', 'scheduledend', 'percentcomplete', 'assignedto', 'tags'].includes(c.name) - ), - }], - onEnableUserQueries: () => true, - onGetUserQueries: async () => [...this._userQueries], - - onCreateUserQuery: async (newQuery, currentQuery) => { - const id = crypto.randomUUID(); - this._userQueries.push({ ...currentQuery, id, name: newQuery.name }); - return id; - }, - onUpdateUserQuery: async (currentQuery) => { - const idx = this._userQueries.findIndex(q => q.id === currentQuery.id); - if (idx >= 0) this._userQueries[idx] = { ...currentQuery }; - return currentQuery.id; - }, - onDeleteUserQueries: async (queryIds) => { - const deleted: string[] = []; - for (const id of queryIds) { - const idx = this._userQueries.findIndex(q => q.id === id); - if (idx >= 0) { this._userQueries.splice(idx, 1); deleted.push(id); } - } - return { success: true, deletedQueryIds: deleted }; - }, - }; - } +| Property | Required | Role | +|----------|:--------:|------| +| `subject` | ✅ | Display name / title. Always pinned left; never hidden. | +| `parentId` | ✅ | Self-referential parent lookup — drives the tree hierarchy. | +| `stackRank` | ✅ | Ordering attribute. Used for default sort and drag-and-drop reordering. | +| `stateCode` | ✅ | Active/inactive status. Used by the *Hide inactive tasks* filter. The Dataverse strategy provides this automatically (always `statecode`) — you do not need to include it in `FieldMapping`. - public onCreateUserQueryDataProvider() { - // The provider backs the rename / description fields in the save-view dialog. - const provider = new MemoryDataProvider({ - dataSource: this._userQueries.map(q => ({ queryid: q.id, name: q.name })), - metadata: { PrimaryIdAttribute: 'queryid', LogicalName: 'mem_userquery' }, - }); - provider.setColumns([ - { name: 'queryid', dataType: 'SingleLine.Text', displayName: 'ID', isHidden: true }, - { name: 'name', dataType: 'SingleLine.Text', displayName: 'Name', visualSizeFactor: 200 }, - ]); - provider.addEventListener('onAfterRecordSaved', (result) => { - if (result.success) { - const updated = this._userQueries.find(q => q.id === result.recordId); - if (updated) updated.name = provider.getRecordsMap()[result.recordId].getValue('name'); - } - }); - return provider; - } +--- - // Optional: returning a non-null provider enables the "Task from Template" button. - public onCreateTemplateDataProvider() { - const provider = new MemoryDataProvider({ dataSource: SAMPLE_TEMPLATES, metadata: TEMPLATE_METADATA }); - provider.setColumns(TEMPLATE_COLUMNS); - return provider; - } +## `ITaskGridParameters` - public onGetGridParameters(): ITaskGridParameters { - return { - height: '600px', - enableRowDragging: true, - enableEditColumns: true, - enableShowHierarchyToggle: true, - enableHideInactiveTasksToggle: true, - enableEditColumnsScopeSelector: false, - }; - } -} -``` +Feature flags returned by `onGetGridParameters`. All properties are optional and **default to `false`** when not set. + +| Property | Description | +|----------|-------------| +| `enableRowDragging` | Show drag handles and allow rows to be reordered by dragging. Suppressed automatically in flat-list mode or when sorted by a non-stack-rank column. | +| `enableEditColumns` | Show the *Edit Columns* ribbon button. | +| `enableTaskEditing` | Allow inline cell editing. | +| `enableTaskCreation` | Show the *New* button. | +| `enableTaskDeletion` | Show the *Delete* button. | +| `enableQuickFind` | Show the quick-find search input. | +| `enableViewSwitcher` | Show the view-switcher dropdown. | +| `enableShowHierarchyToggle` | Show the *Show hierarchy* toggle. | +| `enableHideInactiveTasksToggle` | Show the *Hide inactive tasks* toggle. | +| `enableEditColumnsScopeSelector` | Show the personal/system scope selector inside the Edit Columns panel. | +| `enableUserQueries` | Allow users to create and manage personal saved views. | +| `enableQueryManager` | Show the query manager panel. | +| `enableSaveAsNewQuery` | Show the *Save as new* button in the query manager. | +| `enableSaveQueryChanges` | Show the *Save changes* button in the query manager. | +| `enableCustomColumnCreation` | Allow users to create custom columns. | +| `enableCustomColumnEditing` | Allow users to edit custom column definitions. | +| `enableCustomColumnDeletion` | Allow users to delete custom columns. | +| `enableInlineCreation` | Create new task records inline (in the grid row) instead of opening a form. | +| `enableNavigation` | Enable row navigation (clicking a row opens the record). | +| `enableSorting` | Enable column header sorting. | +| `enableFiltering` | Enable column header filtering. | +| `rowHeight` | Override the default row height in pixels. Uses the AG Grid default when omitted. | --- -## Descriptor interface reference +## `ITaskDataProviderStrategy` -| Method | Required | Description | -|--------|:--------:|-------------| -| `onGetNativeColumns()` | ✅ | Maps logical column roles to physical schema attribute names. | -| `onCreateSavedQueryStrategy()` | ✅ | Returns the strategy for loading and persisting saved views. | -| `onCreateTaskStrategy(deps)` | ✅ | Returns the strategy for all task CRUD, move, and template operations. `deps.templateDataProvider` and `deps.customColumnsDataProvider` are set when those features are enabled. | -| `onCreateUserQueryDataProvider()` | ✅ | Returns an `IDataProvider` that backs the save-view dialog. | -| `onCreateCustomColumnsStrategy?()` | — | Enables dynamic (user-defined) columns. Provide `TalxisCustomColumnsStrategy` for Dataverse or your own `ICustomColumnsStrategy`. | -| `onCreateTemplateDataProvider?()` | — | Enables template-based task creation. Return an `IDataProvider` whose records represent templates. | -| `onCreateGridCustomizerStrategy?()` | — | Deep-customizes AG Grid column definitions, cell renderers, cell editors, and row class rules. | -| `onGetAgGridLicenseKey?()` | — | Supplies the AG Grid Enterprise license key. | -| `onGetControlId?()` | — | Returns a stable DOM identifier for the control. Auto-generated if omitted. | -| `onLoadDependencies?()` | — | Async hook called once before providers are created. Use for authentication or data pre-loading. | -| `onGetGridParameters?()` | — | Returns `ITaskGridParameters` UI feature flags. All flags default to `true` when omitted. | +Handles all data access and mutation for tasks. Return an instance from `onCreateTaskStrategy(deps)`. ---- +`deps` contains: +- `deps.enableTaskEditing` — mirrors `ITaskGridParameters.enableTaskEditing`. Use to conditionally enable inline cell editing in the strategy. +- `deps.enableInlineCreation` — mirrors `ITaskGridParameters.enableInlineCreation`. Use to choose between inline creation and opening a form. +- `deps.templateDataProvider` — present when `onCreateTemplateDataProvider` is implemented. +- `deps.customColumnsDataProvider` — present when `onCreateCustomColumnsStrategy` is implemented. -## `INativeColumns` +### Interface reference -Maps logical roles to the actual attribute names in your entity schema. All fields except `percentComplete` are required. +| Method | Description | +|--------|-------------| +| `onInitialize(provider)` | Called once on first load. Return `{ columns, rawData, metadata }`. Store the `provider` reference for use in other methods. | +| `onGetRawRecords(ids)` | Fetch raw records by id. Pass an empty array to fetch all. | +| `onGetAvailableColumns(options?)` | Return all columns that can be displayed (native + custom). | +| `onGetAvailableRelatedColumns()` | Return linked-entity columns available for filtering and sorting. | +| `onCreateTask(parentTaskId?)` | Create a task, optionally as a child. Return the raw record or `null` for user cancellation. | +| `onDeleteTasks(taskIds)` | Delete tasks. Return a per-task success/failure result. | +| `onMoveTask(movingId, targetId, position)` | Move a task `'above'`, `'below'`, or as `'child'` of target. Return updated records or `null` for cancellation. | +| `onRecordSave(record)` | Persist an inline cell edit. Return `IRecordSaveOperationResult`. | +| `onIsRecordActive(recordId)` | Return `false` for completed/cancelled tasks. Inactive rows receive a greyed-out style. | +| `onOpenDatasetItems(entityReferences, isTaskEntity)` | Called on cell clicks to open records. `isTaskEntity: true` means the references are task records; `false` means a related entity (e.g. a lookup target). | +| `onCreateTemplateFromTask(taskId)` | Create a template from a task. Return raw record or `null`. | +| `onCreateTasksFromTemplate(templateId, parentId?)` | Instantiate tasks from a template. Return created raw records or `null`. | +| `onGetRootTaskId?()` | Scope the tree to a subtree by returning the root task id. | + +### Dataverse example -| Property | Role | -|----------|------| -| `subject` | Display name / title. Always pinned left; the grid never hides it. | -| `parentId` | Lookup to the parent task — drives the tree hierarchy. | -| `stackRank` | Numeric ordering field. Used for default sort and drag-and-drop reordering. | -| `stateCode` | Active/inactive status. Used by the *Hide inactive tasks* filter. | -| `path` | Virtual breadcrumb computed from ancestor names. Marked read-only automatically; does not need to be a real schema field. | -| `percentComplete?` | (Optional) Numeric completion percentage. Rendered with a progress-bar cell renderer. | +When targeting Dataverse, use `DataverseTaskStrategy` from `extensions/dataverse`. The class implements the full `ITaskDataProviderStrategy` interface against the Xrm Web API — subclass it to override only what you need. + +See the [Dataverse strategy](#dataverse-strategy-pre-made) section for a full subclassing example. --- -## `ITaskGridParameters` +## `ISavedQueryStrategy` -Feature flags returned by `onGetGridParameters`. All properties are optional and default to `true`. +Controls how system and user saved views are loaded and persisted. -| Property | Default | Description | -|----------|:-------:|-------------| -| `height` | `null` | CSS height for the grid container. Omit to size the grid to its parent. | -| `enableRowDragging` | `true` | Show drag handles and allow reordering. Suppressed automatically in flat-list mode or when sorted by a non-stack-rank column. | -| `enableEditColumns` | `true` | Show the *Edit Columns* ribbon button. | -| `enableQuickFind` | `true` | Show the quick-find search input. | -| `enableViewSwitcher` | `true` | Show the view-switcher dropdown. | -| `enableShowHierarchyToggle` | `true` | Show the *Show hierarchy* toggle. | -| `enableHideInactiveTasksToggle` | `true` | Show the *Hide inactive tasks* toggle. | -| `enableEditColumnsScopeSelector` | `true` | Show the personal/system scope selector inside the Edit Columns panel. | +| Method | Description | +|--------|-------------| +| `onGetSystemQueries()` | Return built-in (non-deletable) views. **At least one is required.** | +| `onGetUserQueries()` | Return views saved by the current user. | +| `onDeleteUserQueries(queryIds)` | Delete user views. Return a per-query success/failure result. | +| `onUpdateUserQuery(currentQuery)` | Persist changes to an existing view. Return `null` for user cancellation; throw on unexpected failure. | +| `onCreateUserQuery(newQuery, currentQuery)` | Create a new view from the current state. Return `null` for user cancellation; throw on unexpected failure. | +| `onEnableUserQueries?()` | Return `false` to disable personal views entirely. Defaults to `true`. | + +**Built-in implementation:** `DataverseSavedQueryStrategy` — stores user views as `talxis_userquery` records in Dataverse, scoped to a `recordId` and `ownerId`. + +### `ISavedQuery` shape + +| Property | Required | Description | +|----------|:--------:|-------------| +| `id` | ✅ | Stable UUID for this view. | +| `name` | ✅ | Display name shown in the view-switcher. | +| `columns` | ✅ | Array of `IColumn` descriptors. | +| `sorting?` | — | Array of `{ name, sortDirection }`. `0` = ascending, `1` = descending. | +| `filtering?` | — | `FilterExpression` object `{ filterOperator, conditions[], filters[] }`. `0` = OR, `1` = AND. | +| `isFlatListEnabled?` | — | Start this view in flat-list mode. | +| `quickFindColumns?` | — | Attribute names searched by the quick-find input when this view is active. | --- -## `ITaskDataProviderStrategy` +## `IGridCustomizerStrategy` -Handles all data access and mutation for tasks. Implement this and return it from `onCreateTaskStrategy`. +Return this from `onCreateGridCustomizerStrategy` to customize the underlying AG Grid instance. -### Minimal in-memory implementation +| Method | Description | +|--------|-------------| +| `onInitialize(customizer)` | Called once after the grid is ready. Store the `customizer` reference. Subscribe to data provider events here. | +| `onGetColumnDefinitions?(colDefs)` | Receives computed column definitions. Return a modified array. | +| `onGetRowClassRules?(rules)` | Receives the default row CSS class rules. Extend or override and return. | -```ts -export class MemoryTaskStrategy implements ITaskDataProviderStrategy { - private _data = new Map( - TASKS.map(t => [t[PRIMARY_ID] as string, { ...t }]) - ); - private _provider!: ITaskDataProvider; - private _taskTree!: IRecordTree; - - public async onInitialize(provider: ITaskDataProvider) { - // Store the provider — needed in other methods. - this._provider = provider; - this._taskTree = provider.getRecordTree(); - return { - columns: COLUMNS, - rawData: [...this._data.values()], - metadata: { PrimaryIdAttribute: PRIMARY_ID, LogicalName: ENTITY_NAME }, - }; - } +The `IGridCustomizer` passed to `onInitialize` exposes: - public async onGetRawRecords(ids: string[]): Promise { - return ids.flatMap(id => { const r = this._data.get(id); return r ? [r] : []; }); - } +| Method | Description | +|--------|-------------| +| `getGridApi()` | The raw AG Grid `GridApi`. | +| `getTaskDataProvider()` | The `ITaskDataProvider` — use for `getRecordTree()`, `fetchRawRecords()`, `updateTaskData()`, etc. | +| `getDatasetControl()` | The `ITaskGridDatasetControl` runtime interface. | +| `registerExpressionDecorator(columnName, fn)` | Calls `fn()` only when the column exists in the current view. Safe to call unconditionally — no-ops when the column is absent. | - public async onGetAvailableColumns() { return COLUMNS.filter(c => !c.isHidden); } - public async onGetAvailableRelatedColumns() { return []; } - public onGetQuickFindColumns() { return [SUBJECT_COL, 'assignedto', 'tags']; } - - public async onCreateTask(parentTaskId?: string): Promise { - const id = crypto.randomUUID(); - const task: IRawRecord = { - [PRIMARY_ID]: id, - subject: 'New Task', - [PARENT_ID_VALUE_KEY]: parentTaskId ?? null, - [STACK_RANK_COL]: lexoRankBefore(minSiblingRank(parentTaskId, this._data)), - statecode: 0, statuscode: 1, percentcomplete: 0, - }; - this._data.set(id, task); - return task; - } +### Example - public async onDeleteTasks(taskIds: string[]): Promise { - // Recursively collect descendants so orphaned children are removed too. - const toDelete = new Set(); - for (const id of taskIds) this._collectDescendants(id, toDelete); - for (const id of toDelete) this._data.delete(id); - return { success: true, deletedTaskIds: [...toDelete] }; - } +```ts +export class MyGridCustomizer implements IGridCustomizerStrategy { + private _customizer!: IGridCustomizer; - private _collectDescendants(id: string, result: Set): void { - result.add(id); - for (const child of this._taskTree.getNode(id)?.directChildren ?? []) - this._collectDescendants(child.getRecordId(), result); - } + public onInitialize(customizer: IGridCustomizer): void { + this._customizer = customizer; - public async onEditTasks(_ids: string[]) { - // In-memory: editing happens inline — no modal needed. - return null; + customizer.getTaskDataProvider().addEventListener('onRecordLoaded', (record) => { + // Highlight overdue due-dates in red. + this._customizer.registerExpressionDecorator('scheduledend', () => { + record.expressions.ui.setCustomFormattingExpression('scheduledend', (theme) => { + const raw = record.getValue('scheduledend'); + if (!raw) return undefined; + const date = new Date(raw); date.setHours(0, 0, 0, 0); + const today = new Date(); today.setHours(0, 0, 0, 0); + return date < today + ? { backgroundColor: theme.semanticColors.errorBackground } + : undefined; + }); + }); + }); } - public async onMoveTask(movingId: string, targetId: string, position: 'above' | 'below' | 'child'): Promise { - const moving = this._data.get(movingId)!; - const target = this._data.get(targetId)!; - if (position === 'child') { - moving[PARENT_ID_VALUE_KEY] = targetId; - moving[STACK_RANK_COL] = lexoRankBefore(minChildRank(targetId, this._taskTree, this._data)); - } else { - moving[PARENT_ID_VALUE_KEY] = target[PARENT_ID_VALUE_KEY]; - moving[STACK_RANK_COL] = lexoRankBetweenSiblings(targetId, position, this._taskTree, this._data); + public onGetColumnDefinitions(colDefs: ColDef[]): ColDef[] { + for (const colDef of colDefs) { + if (colDef.colId === 'my_priority') { + colDef.cellRenderer = PriorityCellRenderer; + } } - this._data.set(movingId, moving); - return [moving]; + return colDefs; } +} +``` - public async onRecordSave(record: IRecord): Promise { - const existing = this._data.get(record.getRecordId()); - if (!existing) return { success: false, recordId: record.getRecordId(), fields: [], errors: [{ message: 'Not found' }] }; - for (const col of EDITABLE_COLUMNS) { - const val = record.getValue(col); - if (val !== undefined) existing[col] = val; - } - this._data.set(record.getRecordId(), existing); - return { success: true, recordId: record.getRecordId(), fields: EDITABLE_COLUMNS }; - } +### Built-in cell renderers - public async onCreateTemplateFromTask(taskId: string): Promise { - const task = this._data.get(taskId); - if (!task) return null; - const id = crypto.randomUUID(); - const template = { mem_templateid: id, subject: task.subject }; - SAMPLE_TEMPLATES.push(template); - this._templateDataProvider?.setDataSource(SAMPLE_TEMPLATES); - return template; - } +The grid automatically applies built-in renderers when a column's control metadata declares a matching control name. You configure these in the same way as Lookup Many — through the column's `controls` metadata. Currently supported: - public async onCreateTasksFromTemplate(templateId: string, parentId?: string): Promise { - const template = this._templateDataProvider?.getRecordsMap()[templateId]?.getRawData(); - if (!template) return null; - const created: IRawRecord[] = []; - this._createFromNodes(TEMPLATE_CHILDREN[templateId] ?? [], parentId, created); - return created; - } +| Control name | Applied as | Notes | +|---|---|---| +| `PercentComplete` | renderer + editor | Renders a progress bar and inline percentage editor. Set this control name on any numeric percentage column. | +| `LookupMany` / `ColorfulLookupMany` / `PeopleLookupMany` | renderer | See [Lookup-many columns](#lookup-many-columns). Applied automatically to columns whose `metadata.LookupMany` is set. | - public onIsRecordActive(recordId: string): boolean { - const statuscode = this._data.get(recordId)?.statuscode as number; - return statuscode !== 5 && statuscode !== 6; // 5 = Completed, 6 = Cancelled +### Custom cell renderer + +A cell renderer is a React component assigned to `colDef.cellRenderer`. Use `ICellProps` as the props type. Get the `IRecord` instance from `props.data`, then read the column value with `record.getValue(props.colDef!.colId!)`. + +```tsx +import * as React from 'react'; +import { IRecord } from '@talxis/client-libraries'; +import { ICellProps } from '@talxis/base-controls'; + +export const PriorityCellRenderer = (props: ICellProps) => { + const record: IRecord = props.data; + const priority = record.getValue(props.colDef!.colId!) as number | null; + + if (priority === null || priority === undefined) { + return null; } - public onOpenDatasetItem(_ref: ComponentFramework.EntityReference): void { /* no-op */ } + const labels: Record = { 1: 'Low', 2: 'Normal', 3: 'High' }; + const colours: Record = { + 1: '#107c10', + 2: '#0078d4', + 3: '#d83b01', + }; + + return ( + + {labels[priority] ?? String(priority)} + + ); +}; +``` + +Wire it up in `onGetColumnDefinitions`: + +```ts +public onGetColumnDefinitions(colDefs: ColDef[]): ColDef[] { + for (const colDef of colDefs) { + if (colDef.colId === 'my_priority') { + colDef.cellRenderer = PriorityCellRenderer; + } + } + return colDefs; } ``` -### Strategy interface reference +--- + +## `ICustomColumnsStrategy` + +Enables user-defined (dynamic) column definitions. Return an instance from `onCreateCustomColumnsStrategy`. | Method | Description | |--------|-------------| -| `onInitialize(provider)` | Called once on first load. Return `{ columns, rawData, metadata }`. Store the `provider` reference for use in other methods. | -| `onGetRawRecords(ids)` | Fetch raw records by id. An empty array fetches all records. | -| `onGetAvailableColumns(options?)` | Return all columns that can be displayed (native + custom). | -| `onGetAvailableRelatedColumns()` | Return linked-entity columns for filtering/sorting. | -| `onGetQuickFindColumns()` | Return attribute names searched by the quick-find input. | -| `onCreateTask(parentTaskId?)` | Create a task (optionally as a child). Return raw record or `null` for cancellation. | -| `onDeleteTasks(taskIds)` | Delete tasks. Return a per-task success/failure result. | -| `onCreateTemplateFromTask(taskId)` | Create a template from a task. Return raw record or `null`. | -| `onCreateTasksFromTemplate(templateId, parentId?)` | Instantiate tasks from a template. Return raw records or `null`. | -| `onEditTasks(taskIds)` | Open edit form(s). Return updated records or `null` for cancellation. | -| `onMoveTask(movingId, targetId, position)` | Move a task above, below, or as a child. Return updated records or `null` for cancellation. | -| `onRecordSave(record)` | Persist inline cell edits. Return `IRecordSaveOperationResult`. | -| `onIsRecordActive(recordId)` | Return `false` for completed/cancelled tasks. Inactive rows receive a greyed-out style. | -| `onOpenDatasetItem(entityReference, context?)` | Called on non-subject cell clicks. Open the record form or a related record. | -| `onIsTaskAddingEnabled?()` | Defaults to `true`. Return `false` to hide the *New* button. | -| `onIsTaskEditingEnabled?()` | Defaults to `true`. Return `false` to disable inline editing. | -| `onIsTaskDeletingEnabled?()` | Defaults to `true`. Return `false` to hide the *Delete* button. | -| `onGetRootTaskId?()` | Scope the tree to a subtree by returning the root task id. | +| `onRefresh()` | Fetch/reload all custom column definitions. Returns `IColumn[]`. | +| `onGetColumns()` | Return currently cached columns synchronously. | +| `onCreateColumn()` | Open column-creation UI. Return the new column name or `null`. | +| `onDeleteColumn(name)` | Delete the column. Return the column name or `null`. | +| `onUpdateColumn(name)` | Open column-edit UI. Return the updated column name or `null`. | +| `onGetRawRecords()` | Fetch raw records for the custom column values. | +| `onGetRawRecord(recordId)` | Fetch a single raw record by id. | +| `onSaveValue(regardingRecordId, column, value)` | Persist a cell value for a custom column on the given record. | + +**Built-in implementation:** `DataverseCustomColumnsStrategy` — stores definitions in `talxis_attributedefinition` and values in `talxis_attributevalue`. --- -## `IGridCustomizerStrategy` +## Dataverse strategy (pre-made) + +> **⚠️ WIP:** Template-based task creation (`onCreateTemplateFromTask` / `onCreateTasksFromTemplate`) is not yet implemented in the Dataverse strategy. Calling either method will throw. Templating must be handled by a custom `DataverseTaskStrategy` subclass if needed. -Return this from `onCreateGridCustomizerStrategy` to deeply customize the AG Grid instance. +`extensions/dataverse` provides a ready-to-use `ITaskGridDescriptor` + `ITaskDataProviderStrategy` implementation that works against any Dataverse entity via the Xrm Web API and FetchXML. -### Example — custom renderers, editors, and record expressions +> **Stack ranking** — the Dataverse strategy uses the [LexoRank](https://www.npmjs.com/package/lexorank) algorithm for task ordering. The `stackRank` attribute **must be a text column**; the strategy reads and writes LexoRank strings directly. Drag-and-drop reordering is automatically suppressed when the grid is sorted by any other column. + +### Classes + +| Class | Role | +|-------|------| +| `DataverseTaskGridDescriptor` | Drop-in `ITaskGridDescriptor` for Dataverse. Accepts a params object — no subclassing needed for the common case. | +| `DataverseTaskStrategy` | `ITaskDataProviderStrategy` that talks to the Xrm Web API. Used internally by `DataverseTaskGridDescriptor` but can be extended independently. | +| `DataverseSavedQueryStrategy` | `ISavedQueryStrategy` that persists user views as `talxis_userquery` Dataverse records. | +| `DataverseGridCustomizerStrategy` | `IGridCustomizerStrategy` that wires lookup-many cell renderers. Returned by the descriptor automatically. | +| `DataverseCustomColumnsStrategy` | `ICustomColumnsStrategy` backed by `talxis_attributedefinition` / `talxis_attributevalue`. | + +### Using `DataverseTaskGridDescriptor` as-is ```ts -export class YungoGridCustomizerStrategy implements IGridCustomizerStrategy { - private _customizer!: IGridCustomizer; - private _taskDataProvider!: ITaskDataProvider; - private _loadingCells = new Map>(); +import { DataverseTaskGridDescriptor } from '@talxis/base-controls/dist/components/TaskGrid/extensions/dataverse'; + +const descriptor = new DataverseTaskGridDescriptor({ + height: '600px', + onInitialize: async () => ({ + baseFetchXml: ` + + + {% if projectId %} + + + + {% endif %} + + `, + fieldMapping: { + subject: 'talxis_name', + parentId: 'talxis_parentprojecttaskid', + stackRank: 'talxis_stackrankstring', + projectId: 'talxis_projectid', + }, + systemQueries: [ + { + id: '00000000-0000-0000-0000-000000000001', + name: 'All Tasks', + columns: [ + { name: 'talxis_name' }, + { name: 'statecode' }, + { name: 'talxis_stackrankstring', isHidden: true }, + { name: 'talxis_parentprojecttaskid', isHidden: true }, + ], + sorting: [{ name: 'talxis_stackrankstring', sortDirection: 0 }], + }, + ], + projectRecord: { + entityName: 'talxis_project', + id: projectId, + }, + userId: currentUserId, + editFormId: '', + createFormId: '', + bulkEditFormId: '', + gridParameters: { + enableInlineCreation: true, + }, + }), +}); - public onInitialize(customizer: IGridCustomizer): void { - this._customizer = customizer; - this._taskDataProvider = customizer.getTaskDataProvider(); + +``` - this._taskDataProvider.addEventListener('onRecordLoaded', (record) => this._onRecordLoaded(record)); - this._taskDataProvider.addEventListener('onAfterRecordSaved', (result) => this._onAfterRecordSaved(result)); - this._taskDataProvider.taskEvents.addEventListener('onBeforeTasksEdited', (ids) => this._snapshotBeforeEdit(ids)); - this._taskDataProvider.taskEvents.addEventListener('onAfterTasksEdited', (result) => this._refreshAfterEdit(result)); - } +The `baseFetchXml` supports [Liquid](https://shopify.github.io/liquid/) templates. The variable `{{ projectId }}` is automatically injected when a project reference is present. - public onGetColumnDefinitions(colDefs: ColDef[]): ColDef[] { - for (const colDef of colDefs) { - switch (colDef.colId) { - case 'ntg_relatedfinancialmilestoneid': - colDef.cellEditor = MilestoneCellEditor; - break; - case TagDataProvider.TAG_COLUMN_NAME: - colDef.cellRenderer = TagsCellRenderer; - colDef.editable = false; - colDef.suppressKeyboardEvent = () => true; - break; - case AssigneeDataProvider.ASSIGNEE_COLUMN_NAME: - colDef.cellRenderer = AssigneesCellRenderer; - colDef.editable = false; - break; - } - } - return colDefs; - } +### `DataverseTaskGridDescriptor` constructor - private _onRecordLoaded(record: IRecord): void { - // registerExpressionDecorator is a no-op when the column is absent — safe to call unconditionally. - - // Highlight overdue due-dates in red, today's due-date in yellow. - this._customizer.registerExpressionDecorator('scheduledend', () => { - record.expressions.ui.setCustomFormattingExpression('scheduledend', (cellTheme) => { - const raw = record.getValue('scheduledend'); - if (!raw) return undefined; - const date = new Date(raw); date.setHours(0, 0, 0, 0); - const today = new Date(); today.setHours(0, 0, 0, 0); - if (date < today) return { backgroundColor: cellTheme.semanticColors.errorBackground }; - if (date.getTime() === today.getTime()) return { backgroundColor: cellTheme.semanticColors.warningBackground }; - return undefined; - }); - }); +The descriptor takes two parameters: - // Disable effort editing on parent tasks — values are server-aggregated. - this._customizer.registerExpressionDecorator('ntg_estimatedeffort', () => { - record.expressions.setDisabledExpression('ntg_estimatedeffort', () => - !record.isActive() || - this._taskDataProvider.getRecordTree().hasChildren(record.getRecordId()) - ); - }); +| Property | Required | Description | +|----------|:--------:|-------------| +| `onInitialize` | ✅ | Async factory called once before the grid mounts. Resolve all async data here (e.g. current user, project record) before returning the full params object. | +| `height?` | — | CSS height for the grid container (e.g. `"600px"`, `"100%"`). Sizes to parent when omitted. | - // Suppress navigation link on milestone lookups. - this._customizer.registerExpressionDecorator('ntg_relatedfinancialmilestoneid', () => { - record.expressions.ui.setControlParametersExpression('ntg_relatedfinancialmilestoneid', (defaults) => ({ - ...defaults, EnableNavigation: { raw: false } - })); - }); +### `IDataverseTaskGridDescriptorParams` reference - // Show a loading spinner for cells being refreshed in the background. - for (const col of this._taskDataProvider.getColumns()) { - if (!col.isHidden) { - this._customizer.registerExpressionDecorator(col.name, () => { - record.expressions.ui.setLoadingExpression(col.name, () => - this._loadingCells.get(record.getRecordId())?.has(col.name) ?? false - ); - }); - } - } - } +| Property | Required | Description | +|----------|:--------:|-------------| +| `baseFetchXml` | ✅ | FetchXML string, optionally with Liquid template variables. | +| `fieldMapping` | ✅ | `IFieldMapping` (+ optional `projectId`) mapping roles to Dataverse attribute names. | +| `systemQueries` | ✅ | `ISavedQuery[]`. At least one required. | +| `projectRecord?` | — | The project associated with these tasks — either `{ entityName, id }` or a fully hydrated `ISingleRecord`. When provided, its id is injected into Liquid fetch templates as `{{ projectId }}`. | +| `sourceRecord?` | — | An additional record whose id is available in Liquid templates (e.g. a sprint or board). | +| `userId?` | — | Current user GUID. Required for `DataverseSavedQueryStrategy` (user query persistence). | +| `enableUserQueries?` | — | Set to `true` to enable personal saved views backed by `DataverseSavedQueryStrategy`. Defaults to `false`. | +| `gridParameters?` | — | `ITaskGridParameters` feature flags. | +| `rootTaskId?` | — | Scope the tree to a subtree by providing the root task GUID. | +| `editFormId?` | — | Form GUID for single-task edit. | +| `createFormId?` | — | Form GUID for task creation. | +| `bulkEditFormId?` | — | Form GUID for bulk task edit. | +| `enableCascadeDelete?` | — | When `true`, deleting a task also deletes its child tasks. Defaults to `false`. | +| `enableDeletingTasksWithChildren?` | — | When `true`, tasks that have children can be deleted. When `false`, such tasks are excluded from deletion and an error is returned. Defaults to `false`. | - private async _onAfterRecordSaved(result: IRecordSaveOperationResult): Promise { - if (!result.success) return; - const { recordId, fields } = result; +### `IFieldMapping` (Dataverse) - if (['ntg_actualeffort', 'ntg_remainingeffort', 'ntg_estimatedeffort'].some(f => fields.includes(f))) { - // Re-fetch ancestors so aggregated effort values update in the grid. - const node = this._taskDataProvider.getRecordTree().getNode(recordId); - await this._fetchAndShow([recordId, ...node.pathIds.slice(0, -1)], ['ntg_estimatedeffort', 'ntg_actualeffort', 'percentcomplete']); - } +Extends `IFieldMapping` with one additional field used by the Dataverse strategy: - if (fields.includes('statuscode')) { - const status = this._taskDataProvider.getRecordsMap()[recordId].getValue('statuscode'); - if (status === CLOSED || status === CANCELLED) { - const node = this._taskDataProvider.getRecordTree().getNode(recordId); - await this._fetchAndShow([...node.allChildren.map(r => r.getRecordId()), recordId], ['statuscode', 'statecode']); - } - } +| Property | Description | +|----------|-------------| +| `projectId?` | Attribute name of the project lookup on the task entity (e.g. `"talxis_projectid"`). When provided, new tasks are pre-filled with the current project reference. | + +### Extending `DataverseTaskStrategy` + +Subclass `DataverseTaskStrategy` to override individual operations while keeping everything else intact. + +```ts +import { + DataverseTaskStrategy, + IDataverseTaskStrategyParams, +} from '@talxis/base-controls/dist/components/TaskGrid/extensions/dataverse'; +import { IDeleteTasksResult } from '@talxis/base-controls'; + +export class MyTaskStrategy extends DataverseTaskStrategy { + constructor(params: IDataverseTaskStrategyParams) { + super({ + ...params, + // Intercept any form navigation call to inject extra parameters. + form: { + onGetFormParameters: (operation, defaults) => { + if (operation === 'create') { + return { + ...defaults, + pageInput: { + ...defaults.pageInput, + data: { + ...defaults.pageInput.data, + my_customfield: 'default-value', + }, + }, + }; + } + return defaults; + }, + }, + }); } - private async _fetchAndShow(ids: string[], columns: string[]): Promise { - const columnSet = new Set(columns); - for (const id of ids) this._loadingCells.set(id, columnSet); - this._taskDataProvider.requestRender(); - const rawData = await this._taskDataProvider.fetchRawRecords(ids); - this._taskDataProvider.updateTaskData(rawData); - this._loadingCells.clear(); - this._taskDataProvider.requestRender(); + // Override: block deletion when the task has time entries. + public async onDeleteTasks(taskIds: string[]): Promise { + for (const id of taskIds) { + const { entities } = await window.Xrm.WebApi.retrieveMultipleRecords( + 'talxis_timeentry', + `?$filter=talxis_projecttaskid eq '${id}'&$top=1&$select=talxis_timeentryid` + ); + if (entities.length > 0) { + return { + success: false, + deletedTaskIds: [], + errors: [{ id, error: 'Task has time entries and cannot be deleted.' }], + }; + } + } + return super.onDeleteTasks(taskIds); } } ``` -### Strategy interface reference +To use a custom strategy with `DataverseTaskGridDescriptor`, subclass it and override `onCreateTaskStrategy`: -| Method | Description | -|--------|-------------| -| `onInitialize(customizer)` | Called once after the grid is ready. Store the `customizer` reference for later. Subscribe to data provider events here. | -| `onGetColumnDefinitions?(colDefs)` | Receives the computed column definitions and may return a modified array. | -| `onGetRowClassRules?(rules)` | Receives the default row CSS class rules map and may extend or override it. | -| `onGetCellRenderer?(colDef)` | Return a custom AG Grid cell renderer component, or `undefined` for the default. | -| `onGetCellEditor?(colDef)` | Return a custom AG Grid cell editor component, or `undefined` for the default. | -| `onRetrieveGridApi?(gridApi)` | Receive the raw `GridApi` instance if you need a persistent reference. | +```ts +import { DataverseTaskGridDescriptor } from '@talxis/base-controls/dist/components/TaskGrid/extensions/dataverse'; +import { ITaskStrategyDeps } from '@talxis/base-controls'; +import { MyTaskStrategy } from './MyTaskStrategy'; -The `IGridCustomizer` passed to `onInitialize` exposes: +export class MyDescriptor extends DataverseTaskGridDescriptor { + public onCreateTaskStrategy(deps: ITaskStrategyDeps) { + // Access the already-resolved descriptor fields via protected/public members, + // or construct strategy params from your own stored state. + return new MyTaskStrategy(/* your params */, deps); + } +} +``` -| Method | Description | -|--------|-------------| -| `getGridApi()` | The raw AG Grid `GridApi`. | -| `getTaskDataProvider()` | The `ITaskDataProvider` — use for `getRecordTree()`, `fetchRawRecords()`, `updateTaskData()`, etc. | -| `getDatasetControl()` | The `ITaskGridDatasetControl` runtime interface. | -| `registerExpressionDecorator(columnName, registrator)` | Calls `registrator()` only when the column exists in the current view. Safe to call for optional columns; no-ops when absent. | +### `IDataverseTaskStrategyParams` reference + +| Property | Description | +|----------|-------------| +| `fetchXml` | The FetchXML string (Liquid already rendered at this point). | +| `projectRecord?` | Resolved `ISingleRecord` for the project. New tasks are pre-linked to this record. | +| `sourceRecord?` | An additional resolved `ISingleRecord` whose data is available as Liquid variables. | +| `rootTaskId?` | Root task GUID for subtree scoping. | +| `editFormId?` | Form GUID for single-task edit. | +| `createFormId?` | Form GUID for task creation. | +| `bulkEditFormId?` | Form GUID for bulk task edit. | +| `isEditingEnabled?` | When `false`, disables inline cell editing at the strategy level. | +| `isCascadeDeleteEnabled?` | When `true`, deleting a task also deletes its child tasks. Defaults to `false`. | +| `isDeletingTasksWithChildrenEnabled?` | When `true`, tasks that have children can be deleted. When `false`, such tasks are excluded from deletion and an error is returned. Defaults to `false`. | +| `form?.onGetFormParameters` | `(operation, defaults) => params` — intercept and modify any form navigation call (`'create'`, `'edit'`, `'bulkEdit'`, `'open'`). | --- -## `ISavedQueryStrategy` +### Lookup-many columns -Controls how system and user saved views are loaded and persisted. +A lookup-many column surfaces a multi-value relationship (1:N or N:N) directly as a grid cell. `DataverseTaskStrategy` detects lookup-many columns by the presence of `metadata.LookupMany` on the column definition, resolves the OData expand clause via the `ReferencedEntityNavigationPropertyName`, and handles associate/disassociate on save. -| Method | Description | -|--------|-------------| -| `onGetSystemQueries()` | Return built-in (non-deletable) views. **At least one must be returned or the control throws.** | -| `onGetUserQueries()` | Return views saved by the current user. | -| `onDeleteUserQueries(queryIds)` | Delete user views. Return a per-query success/failure result. | -| `onUpdateUserQuery(currentQuery)` | Persist changes to an existing view. Return `null` for user cancellation; throw on unexpected failure. | -| `onCreateUserQuery(newQuery, currentQuery)` | Create a new view. Return `null` for user cancellation; throw on unexpected failure. | -| `onEnableUserQueries?()` | Return `false` to disable personal views entirely. Defaults to `true`. | +#### Column name -**Built-in implementation:** `TalxisSavedQueryStrategy` — stores user views as `talxis_userquery` Dataverse records scoped to a `recordId` and `ownerId`. +The column name can be any unique string. The relationship is identified not by the name but by `metadata.LookupMany.ReferencedEntityNavigationPropertyName` — the OData navigation property name of the relationship on the task entity. ---- +#### Defining the column in a system query -## `ICustomColumnsStrategy` +Pass the column descriptor inside the `columns` array of an `ISavedQuery`. The `controls[0].bindings.FetchXml` binding is **required** — it defines how candidate records are loaded into the picker. -Manages user-defined (dynamic) column definitions. Enable the feature by returning a strategy from `onCreateCustomColumnsStrategy`. +```ts +const COLUMNS: IColumn[] = [ + { name: 'talxis_name', visualSizeFactor: 300 }, + { name: 'statecode', isHidden: true }, + { name: 'talxis_stackrankstring', isHidden: true }, + { name: 'talxis_parentprojecttaskid', isHidden: true }, + { + name: 'tags', // any unique column name + isVirtual: true, + dataType: 'Lookup.Simple', + displayName: 'Tags', + visualSizeFactor: 300, + metadata: { + Targets: ['talxis_tag'], + LookupMany: { + ReferencedEntityNavigationPropertyName: 'talxis_projecttask_talxis_Tag_talxis_Tag', + }, + }, + controls: [{ + appliesTo: 'both', + name: 'ColorfulLookupMany', + bindings: { + FetchXml: { + value: '', + type: 'SingleLine.Text', + }, + ColorPropertyName: { + value: 'talxis_color', + type: 'SingleLine.Text', + }, + }, + }], + }, +]; +``` -| Method | Description | -|--------|-------------| -| `onRefresh()` | Load/reload all custom column definitions. Returns `IColumn[]`. | -| `onGetColumns()` | Return cached columns synchronously. | -| `onCreateColumn()` | Open column-creation UI. Return the new column name or `null`. | -| `onDeleteColumn(name)` | Delete the column. Return the column name or `null`. | -| `onUpdateColumn(name)` | Open column-edit UI. Return the column name or `null`. | +#### Available cell renderers + +| `controls[0].name` | Description | Extra bindings | +|--------------------|-------------|----------------| +| `LookupMany` *(default)* | Generic multi-record picker. | — | +| `ColorfulLookupMany` | Picker with colored badge chips. | `ColorPropertyName` — attribute holding a hex color on the related entity. | +| `PeopleLookupMany` | People-style avatar picker. | `ImageUrlPropertyName` — attribute holding a profile image URL on the related entity. | + +The `FetchXml` binding supports Liquid variables. Two objects are injected at render time: -**Built-in implementation:** `TalxisCustomColumnsStrategy` — stores definitions in `talxis_attributedefinition` and values in `talxis_attributevalue`. Column names are suffixed with a custom-column sentinel so the control can distinguish them from native columns. +| Variable | Description | +|---|---| +| `{{ task.id }}` | GUID of the current task row. | +| `{{ task. }}` | Any raw attribute value on the current task record (e.g. `{{ task.talxis_projectid }}`). | +| `{{ project.id }}` | GUID of the project record, when one was provided to the descriptor. | +| `{{ project. }}` | Any raw attribute value on the project record. | --- ## Localization -Pass a `labels` prop to override any subset of UI strings. The full key set is defined in `ITaskGridLabels`. Some strings support Liquid-style variable interpolation (`{{ variableName }}`): +Pass a `labels` prop to override any subset of UI strings. Some strings support Liquid-style variable interpolation (`{{ variableName }}`): ```tsx ``` +The full set of label keys is defined in `ITaskGridLabels`. + --- ## Replacing UI components @@ -599,4 +659,5 @@ Pass a `components` prop to swap the skeleton loader or the command bar: onRenderCommandBar: (props) => , }} /> -``` \ No newline at end of file +``` + diff --git a/src/components/TaskGrid/TaskGrid.tsx b/src/components/TaskGrid/TaskGrid.tsx index dc2c4e12..9066ef30 100644 --- a/src/components/TaskGrid/TaskGrid.tsx +++ b/src/components/TaskGrid/TaskGrid.tsx @@ -7,13 +7,14 @@ import { DatasetControl as DatasetControlRenderer } from "../DatasetControl"; import { useTheme } from "@fluentui/react"; import { getDatasetControlStyles } from "./styles"; import { Grid } from "./components/grid"; -import { ITaskDataProvider } from "./data-providers/task-data-provider"; -import { ITaskGridLabels, LocalizationService } from "./labels"; +import { ITaskDataProvider } from "./providers/task"; +import { ITaskGridLabels } from "./labels"; import { TASK_GRID_LABELS } from "./labels"; import { ITaskGridState, TaskGridDatasetControlFactory } from "./TaskGridDatasetControlFactory"; import { Header } from "./components/header/Header"; import { ITaskGridComponents, TaskGridComponents } from "./components/components"; import { ITaskGridDescriptor, ITaskGridDatasetControl } from "./interfaces"; +import { LocalizationService } from "../../utils"; interface ITaskGridProps { //should be replaced by Context API in future @@ -35,9 +36,7 @@ export const TaskGrid = (props: ITaskGridProps) => { const components = { ...TaskGridComponents, ...props.components }; const pcfContextRef = useRef(props.pcfContext); pcfContextRef.current = props.pcfContext; - const labelsRef = useRef(); - labelsRef.current = { ...TASK_GRID_LABELS, ...props.labels }; - const localizationService = React.useMemo(() => new LocalizationService(() => labelsRef.current!), []); + const localizationService = React.useMemo(() => new LocalizationService({ ...TASK_GRID_LABELS, ...props.labels }), []); const [instanceState, setInstanceState] = React.useState<{ instance: ITaskGridDatasetControl; @@ -61,14 +60,14 @@ export const TaskGrid = (props: ITaskGridProps) => { if (!instanceState) { return components.onRenderSkeleton({ - height: taskGridDescriptor.onGetGridParameters?.().height ?? '400px' + height: taskGridDescriptor.onGetHeight?.() ?? '400px' }) } return ( - + } public getNativeColumns() { - return this._descriptor.onGetNativeColumns(); + return { + ...this._descriptor.onGetFieldMapping(), + path: PATH_COLUMN_NAME + } } public getLocalizationService() { @@ -63,11 +67,11 @@ export class TaskGridDatasetControl extends EventEmitter } public isRowDraggingEnabled(): boolean { - return this._gridParameters.enableRowDragging ?? true; + return this._gridParameters.enableRowDragging ?? false; } public isEditColumnsScopeSelectorEnabled(): boolean { - return this._gridParameters.enableEditColumnsScopeSelector ?? true; + return this._gridParameters.enableEditColumnsScopeSelector ?? false; } public isTemplatingEnabled(): boolean { @@ -79,22 +83,26 @@ export class TaskGridDatasetControl extends EventEmitter } public isHideInactiveTasksToggleVisible(): boolean { - return this._gridParameters.enableHideInactiveTasksToggle ?? true; + return this._gridParameters.enableHideInactiveTasksToggle ?? false; } public isCustomColumnCreationEnabled(): boolean { - return this._gridParameters.enableCustomColumnCreation ?? true; + return this._gridParameters.enableCustomColumnCreation ?? false; } public isCustomColumnEditingEnabled(): boolean { - return this._gridParameters.enableCustomColumnEditing ?? true; + return this._gridParameters.enableCustomColumnEditing ?? false; } public isCustomColumnDeletionEnabled(): boolean { - return this._gridParameters.enableCustomColumnDeletion ?? true; + return this._gridParameters.enableCustomColumnDeletion ?? false; + } + + public isInlineCreateEnabled(): boolean { + return this._gridParameters.enableInlineCreation ?? false; } public isShowHierarchyToggleVisible(): boolean { - return this._gridParameters.enableShowHierarchyToggle ?? true; + return this._gridParameters.enableShowHierarchyToggle ?? false; } public getInactiveTasksVisibility() { @@ -111,19 +119,19 @@ export class TaskGridDatasetControl extends EventEmitter } public isViewManagerEnabled(): boolean { - return this._gridParameters.enableQueryManager ?? true; + return this._gridParameters.enableQueryManager ?? false; } public isSaveQueryAsNewEnabled(): boolean { - return this._gridParameters.enableSaveAsNewQuery ?? true; + return this._gridParameters.enableSaveAsNewQuery ?? false; } public isSaveQueryChangesEnabled(): boolean { - return this._gridParameters.enableSaveChangesToQuery ?? true; + return this._gridParameters.enableSaveQueryChanges ?? false; } public isUserQueriesFeatureEnabled(): boolean { - return this._gridParameters.enableUserQueries ?? true; + return this._gridParameters.enableUserQueries ?? false; } public getSavedQueryDataProvider() { @@ -170,7 +178,7 @@ export class TaskGridDatasetControl extends EventEmitter if (hide) { const condition = stateCodeFilter.addCondition(); condition.setOperator(Operators.Equal.Value); - condition.setValue([STATE_CODE_ACTIVE]); + condition.setValue(STATE_CODE_ACTIVE); } const filterExpression = filtering.getFilterExpression(Type.And.Value); if (filterExpression) { @@ -199,7 +207,7 @@ export class TaskGridDatasetControl extends EventEmitter return false; } public isQuickFindVisible(): boolean { - return this._gridParameters.enableQuickFind ?? true; + return this._gridParameters.enableQuickFind ?? false; } public isAutoSaveEnabled(): boolean { return true; @@ -208,7 +216,7 @@ export class TaskGridDatasetControl extends EventEmitter return true; } public getHeight(): string | null { - return this._gridParameters.height ?? null; + return this._descriptor.onGetHeight?.() ?? null; } public getDataset(): IDataset { return this._dataset; @@ -219,17 +227,30 @@ export class TaskGridDatasetControl extends EventEmitter public getPcfContext(): ComponentFramework.Context { return this._getPcfContext(); } + public isTaskEditingEnabled(): boolean { + return this._gridParameters.enableTaskEditing ?? false; + } + public isTaskCreatingEnabled(): boolean { + return this._gridParameters.enableTaskCreation ?? false; + } + public isTaskDeletingEnabled(): boolean { + return this._gridParameters.enableTaskDeletion ?? false; + } + public isNavigationEnabled(): boolean { + return this._gridParameters.enableNavigation ?? false; + } + public getParameters(): IDatasetControlParameters { return { Grid: this.getDataset(), EnableEditing: { - raw: this._dataProvider.isTaskEditingEnabled() + raw: this.isTaskEditingEnabled() }, EnableAutoSave: { raw: true }, EnableEditColumns: { - raw: this._gridParameters.enableEditColumns ?? true + raw: this.isEditColumnsVisible() }, EnableZebra: { raw: false @@ -237,9 +258,22 @@ export class TaskGridDatasetControl extends EventEmitter EnableOptionSetColors: { raw: true }, + EnableNavigation: { + raw: this.isNavigationEnabled() + }, Height: { raw: this.getHeight() + }, + EnableSorting: { + raw: this._gridParameters.enableSorting ?? false + }, + EnableFiltering: { + raw: this._gridParameters.enableFiltering ?? false + }, + RowHeight: { + raw: this._gridParameters.rowHeight ?? null } + } } public async loadCommands(ids: string[]): Promise { @@ -256,8 +290,13 @@ export class TaskGridDatasetControl extends EventEmitter return true; } public isEditColumnsVisible(): boolean { - return true; + return this._gridParameters.enableEditColumns ?? false; + } + //required like this since task grid is using its own view switcher + public isViewSwitcherEnabled(): boolean { + return this._gridParameters.enableViewSwitcher ?? false; } + //hide the native one public isViewSwitcherVisible(): boolean { return false; } @@ -288,6 +327,12 @@ export class TaskGridDatasetControl extends EventEmitter this._state.savedQuery = { id: this._changeToQueryId } + //@ts-ignore + if(this._state.AgGridState) { + //clean up AgGrid state as it might not be compatible with new query + //@ts-ignore + delete this._state.AgGridState; + } } else { const currentQueryId = this._savedQueryDataProvider.getCurrentQuery().id; @@ -305,18 +350,9 @@ export class TaskGridDatasetControl extends EventEmitter private _loadState(state: ITaskGridState) { let currentQuery = this._savedQueryDataProvider.getCurrentQuery(); - if (state.savedQuery) { - currentQuery = { - ...currentQuery, - ...state.savedQuery, - } - } - else { + if (!state.savedQuery) { state.savedQuery = currentQuery; } - //some required columns might have been discared by edit columns - this._savedQueryDataProvider.includeRequiredColumns(currentQuery.columns); - this._savedQueryDataProvider.harmonizeColumns(currentQuery.columns); //at this point current query might be missing required properties let { filtering, sorting, columns, searchQuery, linking } = currentQuery; this._dataProvider.setColumns(columns); @@ -354,13 +390,13 @@ export class TaskGridDatasetControl extends EventEmitter this._dataProvider.taskEvents.addEventListener('onBeforeTasksDeleted', () => this._dataProvider.setLoading(true)); this._dataProvider.taskEvents.addEventListener('onAfterTasksDeleted', (result) => this._onAfterTasksDeleted(result)); this._dataProvider.taskEvents.addEventListener('onBeforeTaskMoved', () => this._dataProvider.setLoading(true)); - this._dataProvider.taskEvents.addEventListener('onBeforeTasksEdited', () => this._dataProvider.setLoading(true)); - this._dataProvider.taskEvents.addEventListener('onAfterTasksEdited', () => this._dataProvider.setLoading(false)); this._dataProvider.taskEvents.addEventListener('onBeforeTemplateCreated', () => this._dataProvider.setLoading(true)); this._dataProvider.taskEvents.addEventListener('onAfterTemplateCreated', () => this._dataProvider.setLoading(false)); this._dataProvider.taskEvents.addEventListener('onBeforeTasksCreated', () => this._dataProvider.setLoading(true)); this._dataProvider.taskEvents.addEventListener('onAfterTasksCreated', () => this._dataProvider.setLoading(false)); this._dataProvider.taskEvents.addEventListener('onAfterTaskMoved', () => this._dataProvider.setLoading(false)); + this._dataProvider.taskEvents.addEventListener('onBeforeDatasetItemsOpened', () => this._dataProvider.setLoading(true)); + this._dataProvider.taskEvents.addEventListener('onAfterDatasetItemsOpened', () => this._dataProvider.setLoading(false)); this._savedQueryDataProvider.queryEvents.addEventListener('onAfterUserQueryCreated', (result) => this._onAfterUserQueryCreated(result)); this._savedQueryDataProvider.queryEvents.addEventListener('onAfterUserQueryUpdated', (result) => this._dataProvider.setLoading(false)); } diff --git a/src/components/TaskGrid/TaskGridDatasetControlFactory.ts b/src/components/TaskGrid/TaskGridDatasetControlFactory.ts index b32b75b4..bad7fed3 100644 --- a/src/components/TaskGrid/TaskGridDatasetControlFactory.ts +++ b/src/components/TaskGrid/TaskGridDatasetControlFactory.ts @@ -1,9 +1,9 @@ import { Dataset } from "@talxis/client-libraries"; -import { ITaskDataProvider, TaskDataProvider } from "./data-providers/task-data-provider"; -import { ILocalizationService, ITaskGridLabels } from "./labels"; -import { ISavedQuery, ISavedQueryDataProvider, SavedQueryDataProvider } from "./data-providers/saved-query-data-provider"; -import { RecordTree } from "./data-providers/task-data-provider/record-tree"; -import { CustomColumnsDataProvider } from "./data-providers/custom-columns-data-provider/CustomColumnsDataProvider"; +import { ITaskDataProvider, TaskDataProvider } from "./providers/task"; +import { ILocalizationService } from "../../utils"; +import { ITaskGridLabels } from "./labels"; +import { ISavedQuery, ISavedQueryDataProvider, PATH_COLUMN_NAME, SavedQueryDataProvider } from "./providers/saved-query"; +import { CustomColumnsDataProvider } from "./providers/custom-columns/CustomColumnsDataProvider"; import { ITaskGridDatasetControl, ITaskGridDescriptor } from "./interfaces"; import { TaskGridDatasetControl } from "./TaskGridDatasetControl"; @@ -33,7 +33,8 @@ export class TaskGridDatasetControlFactory { const savedQueryStrategy = parameters.taskGridDescriptor.onCreateSavedQueryStrategy(); const savedQueryDataProvider = new SavedQueryDataProvider(savedQueryStrategy, { - nativeColumns: parameters.taskGridDescriptor.onGetNativeColumns(), + localizationService: parameters.localizationService, + nativeColumns: { ...parameters.taskGridDescriptor.onGetFieldMapping(), path: PATH_COLUMN_NAME }, customColumnsDataProvider: customColumnsDataProvider, preferredQuery: parameters.state.savedQuery, }) @@ -43,12 +44,16 @@ export class TaskGridDatasetControlFactory { const taskStrategy = parameters.taskGridDescriptor.onCreateTaskStrategy({ templateDataProvider: templateDataProvider, customColumnsDataProvider: customColumnsDataProvider, + enableTaskEditing: parameters.taskGridDescriptor.onGetGridParameters?.()?.enableTaskEditing ?? false, + enableInlineCreation: parameters.taskGridDescriptor.onGetGridParameters?.()?.enableInlineCreation ?? false, }) taskDataProvider = new TaskDataProvider({ localizationService: parameters.localizationService, - nativeColumns: parameters.taskGridDescriptor.onGetNativeColumns(), + nativeColumns: { ...parameters.taskGridDescriptor.onGetFieldMapping(), path: PATH_COLUMN_NAME }, strategy: taskStrategy, + savedQueryDataProvider: savedQueryDataProvider, + customColumnsDataProvider: customColumnsDataProvider, onIsFlatListEnabled: () => TaskGridDatasetControlFactory._getIsFlatlistEnabled(parameters, savedQueryDataProvider) }); diff --git a/src/components/TaskGrid/components/grid/cell-renderers/add-task-button/AddTaskButton.tsx b/src/components/TaskGrid/components/grid/cell-renderers/add-task-button/AddTaskButton.tsx index 4be0d494..29137069 100644 --- a/src/components/TaskGrid/components/grid/cell-renderers/add-task-button/AddTaskButton.tsx +++ b/src/components/TaskGrid/components/grid/cell-renderers/add-task-button/AddTaskButton.tsx @@ -13,7 +13,8 @@ export const AddTaskButton = (props: ICellProps) => { const datasetControl = useDatasetControl(); const localizationService = useLocalizationService(); const [isButtonMounted, setIsButtonMounted] = React.useState(true); - const isTaskAddingEnabled = taskDataProvider.isTaskAddingEnabled(); + const isTaskAddingEnabled = datasetControl.isTaskCreatingEnabled(); + const isTemplatingEnabled = datasetControl.isTemplatingEnabled(); const addTaskFromTemplate = async (templateId: string) => { //this needs to be done so the button menu does not overlay the dialog @@ -37,7 +38,7 @@ export const AddTaskButton = (props: ICellProps) => { iconProps: { iconName: 'Add' }, - onClick: () => {taskDataProvider.createTask(record.getRecordId())} + onClick: () => { taskDataProvider.createTask(record.getRecordId()) } }, { key: 'divider', @@ -79,15 +80,10 @@ export const AddTaskButton = (props: ICellProps) => { else if (isButtonVisible()) { return + iconProps={{ iconName: 'Add' }} + onClick={!isTemplatingEnabled ? () => taskDataProvider.createTask(record.getRecordId()) : undefined} + menuProps={isTemplatingEnabled ? { items: getMenuItems() } : undefined} + styles={{ menuIcon: styles.addTaskMenuIcon }} /> } else { return <> diff --git a/src/components/TaskGrid/components/grid/cell-renderers/lookup-many/LookupManyCellRenderer.tsx b/src/components/TaskGrid/components/grid/cell-renderers/lookup-many/LookupManyCellRenderer.tsx new file mode 100644 index 00000000..d7e19af6 --- /dev/null +++ b/src/components/TaskGrid/components/grid/cell-renderers/lookup-many/LookupManyCellRenderer.tsx @@ -0,0 +1,126 @@ +import { IDataProvider, IRecord } from "@talxis/client-libraries"; +import { useTaskDataProvider } from "../../../../.."; +import React, { useCallback, useEffect } from "react"; +import AsyncSelect from "react-select/async"; +import { ICellProps } from "../../../../../Grid/cells/cell/Cell"; +import { ColorfulLookupMany, ILookupManyProps, LookupMany, PeopleLookupMany } from "../../lookup-many"; +import { useAgGridInstance } from "../../../../../Grid/grid/ag-grid/useAgGridInstance"; +import { useGridInstance } from "../../../../../Grid/grid/useGridInstance"; +import { ThemeProvider } from "@fluentui/react"; + +interface ICellRendererProps extends ICellProps { + dataProvider: IDataProvider; +} + +enum ControlName { + LookupMany = 'LookupMany', + PeopleLookupMany = 'PeopleLookupMany', + ColorfulLookupMany = 'ColorfulLookupMany', +} + +export const LookupManyCellRenderer = (props: ICellRendererProps) => { + const { api, baseColumn, dataProvider } = props; + const record: IRecord = props.data; + const [isDisabled, setIsDisabled] = React.useState(true); + const customControl = record.getColumnInfo(baseColumn.name).ui.getCustomControls([])?.[0]; + const controlName = (customControl?.name ?? ControlName.LookupMany) as ControlName; + const bindings = customControl?.bindings; + const provider = useTaskDataProvider(); + const isNavigationEnabled = useGridInstance().isNavigationEnabled(); + const value: ComponentFramework.EntityReference[] | undefined = record.getValue(props.colDef!.colId!) as ComponentFramework.EntityReference[] | undefined; + + const onSelectionChange = (selectedRecords: ComponentFramework.EntityReference[]) => { + record.setValue(props.colDef!.colId!, selectedRecords); + record.save(); + api.refreshCells({ + rowNodes: [props.node], + columns: [props.colDef!.colId!], + force: true + }); + } + + const onRecordOpen = (entityReference: ComponentFramework.EntityReference) => { + provider.openDatasetItem(entityReference, { + columnName: baseColumn.name + }); + } + + const onMenuClose = () => { + setIsDisabled(true); + } + + const getComponentProps = (): ILookupManyProps => { + return { + dataProvider, + selectedRecords: value, + isDisabled, + onRecordSelect: onSelectionChange, + onRecordOpen: isNavigationEnabled ? onRecordOpen : undefined, + components: { + onRenderSelect: (selectProps) => + { + return { + ...selectProps.styles?.control?.(base, props), + maxHeight: 200, + overflow: 'auto', + border: 'none', + background: 'none', + boxShadow: 'none', + } + } + }} + onMenuClose={() => { + selectProps.onMenuClose?.(); + onMenuClose(); + }} /> + } + } + } + + const getComponent = (): JSX.Element => { + switch (controlName) { + case ControlName.ColorfulLookupMany: + return + case ControlName.PeopleLookupMany: + return + default: { + return + } + } + } + const onSwitchToEditMode = useCallback(() => { + if (props.value.editable) { + setIsDisabled(false); + } + setTimeout(() => { + const element = props.eGridCell.querySelector('[data-value]'); + element?.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 })); + element?.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, button: 0 })); + }) + }, [props.value.editable]); + + useEffect(() => { + props.eGridCell.addEventListener('dblclick', onSwitchToEditMode); + return () => { + props.eGridCell.removeEventListener('dblclick', onSwitchToEditMode); + } + }, [onSwitchToEditMode]); + + return + {getComponent()} + +} \ No newline at end of file diff --git a/src/components/TaskGrid/components/grid/cell-renderers/lookup-many/index.ts b/src/components/TaskGrid/components/grid/cell-renderers/lookup-many/index.ts new file mode 100644 index 00000000..fc79a07e --- /dev/null +++ b/src/components/TaskGrid/components/grid/cell-renderers/lookup-many/index.ts @@ -0,0 +1 @@ +export * from './LookupManyCellRenderer'; \ No newline at end of file diff --git a/src/components/TaskGrid/components/grid/cell-renderers/percent-complete/PercentComplete.tsx b/src/components/TaskGrid/components/grid/cell-renderers/percent-complete/PercentComplete.tsx index e39ea1d9..ca49a57b 100644 --- a/src/components/TaskGrid/components/grid/cell-renderers/percent-complete/PercentComplete.tsx +++ b/src/components/TaskGrid/components/grid/cell-renderers/percent-complete/PercentComplete.tsx @@ -3,9 +3,10 @@ import { Cell, ICellProps } from "../../../../../Grid/cells/cell/Cell"; import * as React from "react"; import { getPercentCompleteStyles } from "./styles"; +export const PERCENT_COMPLETE_CONTROL_NAME = "PercentComplete"; + export const PercentComplete = (props: ICellProps) => { - const value = props.value.value; - const formattedValue = props.valueFormatted; + const value: number | null = props.value.value; const styles = React.useMemo(() => getPercentCompleteStyles(), []); if(props.value.loading) { @@ -16,10 +17,9 @@ export const PercentComplete = (props: ICellProps) => { barHeight={5} styles={{ root: styles.progressIndicatorRoot, - itemProgress: styles.itemProgress, - itemDescription: styles.itemDescription + itemProgress: styles.itemProgress }} - description={formattedValue} + description={`${value?.toString() ?? '0'}%`} percentComplete={value !== null ? value / 100 : 0} /> diff --git a/src/components/TaskGrid/components/grid/cell-renderers/percent-complete/styles.ts b/src/components/TaskGrid/components/grid/cell-renderers/percent-complete/styles.ts index a3caac8d..32250b73 100644 --- a/src/components/TaskGrid/components/grid/cell-renderers/percent-complete/styles.ts +++ b/src/components/TaskGrid/components/grid/cell-renderers/percent-complete/styles.ts @@ -17,11 +17,5 @@ export const getPercentCompleteStyles = () => { itemProgress: { flexGrow: 1 }, - itemDescription: { - '::after': { - content: '"%"', - display: 'inline' - } - } }) } \ No newline at end of file diff --git a/src/components/TaskGrid/components/grid/grid-customizer/GridCustomizer.ts b/src/components/TaskGrid/components/grid/grid-customizer/GridCustomizer.ts index 10cdcba7..83732e30 100644 --- a/src/components/TaskGrid/components/grid/grid-customizer/GridCustomizer.ts +++ b/src/components/TaskGrid/components/grid/grid-customizer/GridCustomizer.ts @@ -1,12 +1,13 @@ import { ColDef as ColDefBase, GridApi as GridApiBase, IRowNode, IsServerSideGroupOpenByDefaultParams, RowClassRules as RowClassRulesBase } from "@ag-grid-community/core"; -import { ITaskDataProvider } from "../../../data-providers/task-data-provider"; -import { DatasetConstants, IRawRecord, IRecord } from "@talxis/client-libraries"; +import { ITaskDataProvider } from "../../../providers/task"; +import { DatasetConstants, IColumn, IRawRecord, IRecord } from "@talxis/client-libraries"; import { GridDragHandler, IDragOperation } from "../grid-drag-handler"; import { GroupCell } from "../group-cell"; import { TreeExpandCollapseHeader } from "../cell-headers/tree-expand-collapse-header"; import { AddTaskButton } from "../cell-renderers/add-task-button"; -import { ILocalizationService, ITaskGridLabels } from "../../../labels"; -import { PercentComplete } from "../cell-renderers/percent-complete"; +import { ILocalizationService } from "../../../../../utils"; +import { ITaskGridLabels } from "../../../labels"; +import { PERCENT_COMPLETE_CONTROL_NAME, PercentComplete } from "../cell-renderers/percent-complete"; import { INativeColumns, ITaskGridDatasetControl } from "../../../interfaces"; export const ADD_TASK_COLUMN_NAME = 'addTask'; @@ -24,12 +25,6 @@ export interface IGridCustomizerStrategy { onGetColumnDefinitions?: (columnDefs: ColDef[]) => ColDef[]; /** Receives the default row class rules map and may return an extended or overridden version. */ onGetRowClassRules?: (rules: RowClassRules) => RowClassRules; - /** Return a custom cell renderer component for the given column definition, or `undefined` to use the default. */ - onGetCellRenderer?: (colDef: ColDef) => any; - /** Return a custom cell editor component for the given column definition, or `undefined` to use the default. */ - onGetCellEditor?: (colDef: ColDef) => any; - /** Receives the raw AG Grid `GridApi` instance, useful if the strategy needs to retain a reference. */ - onRetrieveGridApi?: (gridApi: GridApi) => void; } /** Provides access to the AG Grid instance and the TaskGrid control to code running inside `IGridCustomizerStrategy`. */ @@ -111,7 +106,7 @@ export class GridCustomizer implements IGridCustomizer { break; } case 'isServerSideGroupOpenByDefault': { - originalSetGridOption(key, (params: IsServerSideGroupOpenByDefaultParams) => this._isServerSideGroupOpenByDefault(params)); + originalSetGridOption(key, (params: IsServerSideGroupOpenByDefaultParams) => this._isServerSideGroupOpenByDefault(params, (params) => value(params))); break; } default: { @@ -121,16 +116,20 @@ export class GridCustomizer implements IGridCustomizer { } } - private _isServerSideGroupOpenByDefault(params: IsServerSideGroupOpenByDefaultParams) { - if (!params.data) { - return false; + private _isServerSideGroupOpenByDefault(params: IsServerSideGroupOpenByDefaultParams, defaultAction: (params: IsServerSideGroupOpenByDefaultParams) => boolean): boolean { + if (!params.data || this._taskDataProvider.isFlatListEnabled()) { + return false } const matchingRecords = this._taskDataProvider.getRecordTree().getMatchingRecords(); - return !matchingRecords[params.data.getRecordId()]; + const result = !matchingRecords[params.data.getRecordId()]; + if(result) { + return result; + } + return defaultAction(params); } private _injectAddTaskColumn(columnDefs: ColDef[]) { - if (!columnDefs.find(colDef => colDef.colId === ADD_TASK_COLUMN_NAME)) { + if (!this._taskDataProvider.isFlatListEnabled() && !columnDefs.find(colDef => colDef.colId === ADD_TASK_COLUMN_NAME)) { columnDefs.push({ colId: ADD_TASK_COLUMN_NAME, headerName: '', @@ -150,8 +149,10 @@ export class GridCustomizer implements IGridCustomizer { private _getColumnDefinitions(columnDefs: ColDef[]) { this._injectAddTaskColumn(columnDefs); for (const colDef of columnDefs) { - colDef.onCellDoubleClicked = () => { } const columnName = colDef.colId as string; + const column = this._taskDataProvider.getColumnsMap()[columnName]; + const customCellRenderer = this._getCustomControlForColumn('renderer', column); + const customCellEditor = this._getCustomControlForColumn('editor', column); switch (columnName) { case this._nativeColumns.subject: { colDef.cellRenderer = GroupCell; @@ -162,23 +163,32 @@ export class GridCustomizer implements IGridCustomizer { colDef.lockPosition = true; break; } - case this._nativeColumns.percentComplete: { + } + switch(customCellRenderer) { + case PERCENT_COMPLETE_CONTROL_NAME: { colDef.cellRenderer = PercentComplete; - break + break; + } + } + switch (customCellEditor) { + case PERCENT_COMPLETE_CONTROL_NAME: { + colDef.cellEditor = PercentComplete; + break; } } } columnDefs.sort((a, b) => this._getColumnPriority(a) - this._getColumnPriority(b)); columnDefs = this._strategy?.onGetColumnDefinitions?.(columnDefs) ?? columnDefs; - for (const colDef of columnDefs) { - colDef.cellRenderer = this._strategy?.onGetCellRenderer?.(colDef) ?? colDef.cellRenderer; - colDef.cellEditor = this._strategy?.onGetCellEditor?.(colDef) ?? colDef.cellEditor; - } return columnDefs; } + private _getCustomControlForColumn(role: 'editor' | 'renderer', column?: IColumn): string | null { + const control = column?.controls?.find(c => c.appliesTo === role || c.appliesTo === 'both'); + return control?.name ?? null; + } + private _getColumnPriority(col: ColDef): number { if (col.colId === DatasetConstants.CHECKBOX_COLUMN_KEY) return 0; if (col.colId === ADD_TASK_COLUMN_NAME) return 1; @@ -346,7 +356,9 @@ export class GridCustomizer implements IGridCustomizer { }); if (position === 'child') { - overNode.setExpanded(true); + setTimeout(() => { + overNode.setExpanded(true); + }, 0); } this._gridApi.refreshCells({ columns: [this._nativeColumns.subject], @@ -364,11 +376,26 @@ export class GridCustomizer implements IGridCustomizer { parentNode.setExpanded(true); } } - if (!parentId) { - this._gridApi.ensureIndexVisible(0); - } + setTimeout(() => { + const primaryIdAttribute = this._taskDataProvider.getMetadata().PrimaryIdAttribute; + const recordId = records[0][primaryIdAttribute] as string; + const node = this._gridApi.getRowNode(recordId); + if (!node) return; + if (records.length === 1 && this._datasetControl.isInlineCreateEnabled()) { + const rowIndex = node.rowIndex!; + this._gridApi.startEditingCell({ + rowIndex: rowIndex, + colKey: this._nativeColumns.subject + }); + } + else { + this._gridApi.setFocusedCell(node!.rowIndex!, this._nativeColumns.subject); + this._gridApi.ensureNodeVisible(node!); + } + }, 100); } + private _onAfterTaskDataUpdated = (newData: IRawRecord[]) => { const recordIdsSet = new Set(newData.map(item => item[this._taskDataProvider.getMetadata().PrimaryIdAttribute])); const nodes = this._gridApi.getRenderedNodes().filter(node => recordIdsSet.has(node.id!)); diff --git a/src/components/TaskGrid/components/grid/grid-drag-handler/GridDragHandler.ts b/src/components/TaskGrid/components/grid/grid-drag-handler/GridDragHandler.ts index 5ac0571a..3cc79836 100644 --- a/src/components/TaskGrid/components/grid/grid-drag-handler/GridDragHandler.ts +++ b/src/components/TaskGrid/components/grid/grid-drag-handler/GridDragHandler.ts @@ -1,6 +1,6 @@ import { GridApi, IRowNode, RowDragEvent } from "@ag-grid-community/core"; import { EventEmitter, IRecord } from "@talxis/client-libraries"; -import { ITaskDataProvider } from "../../../data-providers/task-data-provider"; +import { ITaskDataProvider } from "../../../providers/task"; import { INativeColumns, ITaskGridDatasetControl } from "../../../interfaces"; interface IGridDragHandlerParameters { diff --git a/src/components/TaskGrid/components/grid/group-cell/styles.ts b/src/components/TaskGrid/components/grid/group-cell/styles.ts index 3a7b1ca1..17d4a40f 100644 --- a/src/components/TaskGrid/components/grid/group-cell/styles.ts +++ b/src/components/TaskGrid/components/grid/group-cell/styles.ts @@ -10,6 +10,9 @@ export const getGroupCellStyles = (theme: ITheme, expanded: boolean) => { '.ms-Link': { fontWeight: '600' }, + '[class^="valueContainer"]>span': { + fontWeight: '600', + }, '>div': { overflow: 'hidden' } diff --git a/src/components/TaskGrid/components/grid/lookup-many/LookupMany.tsx b/src/components/TaskGrid/components/grid/lookup-many/LookupMany.tsx new file mode 100644 index 00000000..9ac81b04 --- /dev/null +++ b/src/components/TaskGrid/components/grid/lookup-many/LookupMany.tsx @@ -0,0 +1,101 @@ +import { IDataProvider } from '@talxis/client-libraries'; +import * as React from 'react'; +import { useLocalizationService } from '../../../context'; +import { MultiValueRemove } from './components/multi-value-remove/MultiValueRemove'; +import { LookupManyComponents, ILookupManyComponents } from './components/components'; +import { LookupManyPropsContext } from './context'; + + + +export interface ILookupManyProps { + dataProvider: IDataProvider; + selectedRecordHeight?: number; + isDisabled?: boolean; + selectedRecords?: ComponentFramework.EntityReference[]; + components?: Partial; + onRecordSelect?: (selectedRecords: ComponentFramework.EntityReference[]) => void; + onRecordOpen?: (record: ComponentFramework.EntityReference) => void; +} + +//can be used as base for new lookup (is task grid independent) + +export const LookupMany = (props: ILookupManyProps) => { + const { dataProvider, selectedRecords = [], isDisabled = false, onRecordSelect, onRecordOpen } = props; + const components = { ...LookupManyComponents, ...props.components }; + const defaultOptionsPromiseRef = React.useRef | null>(null); + const defaultOptionsResolveRef = React.useRef(null); + const localizationService = useLocalizationService(); + const hasBeenUnmountedRef = React.useRef(false); + const MultiValueContainerComponent = React.useRef(components.onRenderMultiValueContainer); + const MultiValueLabel = React.useRef(components.onRenderMultiValueLabel); + const Menu = React.useRef(components.onRenderMenu); + const Option = React.useRef(components.onRenderOption); + + const onLoadOptions = async (inputValue: string): Promise => { + if (!defaultOptionsPromiseRef.current) { + defaultOptionsPromiseRef.current = new Promise((resolve) => { + defaultOptionsResolveRef.current = resolve; + }); + } + await defaultOptionsPromiseRef.current; + if(hasBeenUnmountedRef.current) return []; + dataProvider.setSearchQuery(inputValue); + const records = await dataProvider.refresh(); + return records.map(record => { + return { + ...record.getNamedReference(), + rawData: record.getRawData() + } + }) + } + + //resolve the promise so initial load starts + const onFocus = () => { + setTimeout(() => { + defaultOptionsResolveRef.current?.(); + }, 0); + } + + React.useEffect(() => { + return () => { + hasBeenUnmountedRef.current = true; + defaultOptionsResolveRef.current?.(); + } + }, []); + + + return + {components.onRenderSelect({ + isMulti: true, + isDisabled: isDisabled, + menuPortalTarget: document.body, + value: selectedRecords, + menuPlacement: 'auto', + placeholder: '', + isClearable: false, + menuShouldScrollIntoView: false, + closeMenuOnSelect: false, + //@ts-ignore + defaultOptions: true, + components: { + IndicatorSeparator: () => <>, + DropdownIndicator: () => <>, + LoadingIndicator: () => <>, + MultiValueContainer: MultiValueContainerComponent.current, + MultiValueRemove: MultiValueRemove, + MultiValueLabel: MultiValueLabel.current, + Option: Option.current, + Menu: Menu.current + }, + + noOptionsMessage: () => localizationService.getLocalizedString('noRecordsFound'), + loadingMessage: () => localizationService.getLocalizedString('loading'), + getOptionValue: (record) => record.id.guid, + getOptionLabel: (record) => record.name, + onChange: (selectedRecords) => onRecordSelect?.(selectedRecords as ComponentFramework.EntityReference[]), + onFocus: onFocus, + loadOptions: onLoadOptions, + onNavigate: onRecordOpen + })} + +} diff --git a/src/components/TaskGrid/components/grid/lookup-many/colorful-lookup-many/ColorfulLookupMany.tsx b/src/components/TaskGrid/components/grid/lookup-many/colorful-lookup-many/ColorfulLookupMany.tsx new file mode 100644 index 00000000..ff6c3eca --- /dev/null +++ b/src/components/TaskGrid/components/grid/lookup-many/colorful-lookup-many/ColorfulLookupMany.tsx @@ -0,0 +1,17 @@ +import { ILookupManyProps, LookupMany } from "../LookupMany"; +import { DEFAULT_TAG_LOOKUP_MANY_COMPONENTS } from "./components"; +import { ColorfulLookupManyPropsContext } from "./context"; + +export interface IColorfulLookupManyProps extends ILookupManyProps { + colorPropertyName?: string; +} + +export const ColorfulLookupMany = (props: IColorfulLookupManyProps) => { + const components = { ...DEFAULT_TAG_LOOKUP_MANY_COMPONENTS, ...props.components }; + return + + +} diff --git a/src/components/TaskGrid/components/grid/lookup-many/colorful-lookup-many/components/index.tsx b/src/components/TaskGrid/components/grid/lookup-many/colorful-lookup-many/components/index.tsx new file mode 100644 index 00000000..475e4e57 --- /dev/null +++ b/src/components/TaskGrid/components/grid/lookup-many/colorful-lookup-many/components/index.tsx @@ -0,0 +1,9 @@ +import { LookupManyComponents, ILookupManyComponents } from "../../components/components"; +import { MultiValueContainer } from "./multi-value-container"; +import { Option } from "./option"; + +export const DEFAULT_TAG_LOOKUP_MANY_COMPONENTS: ILookupManyComponents = { + ...LookupManyComponents, + onRenderMultiValueContainer: (props) => , + onRenderOption: (props) =>