Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,43 +1,21 @@
import {Type} from '@angular/core';
import {QueryVariables} from '../../../classes/query-variable-manager';
import {UntypedModelService} from '../../../types/types';
import {type ExtractTallOne, type ExtractVall, UntypedModelService} from '../../../types/types';

export type NaturalHierarchicConfiguration<T extends UntypedModelService = UntypedModelService> = {
export type NodeConfig<T extends UntypedModelService = UntypedModelService> = {
/**
* An AbstractModelService to be used to fetch items
*/
service: Type<T>;

/**
* A list of FilterConditionField name to filter items
*
* Those will be used directly to build filter to fetch items, so they must be
* valid API FilterConditionField names for the given service.
*
* Eg: given the QuestionService, possible names would be:
*
* - "chapter" to filter by the question's chapter
* - "parent" to filter by the question's parent question
*/
parentsRelationNames?: string[];

/**
* A list of FilterConditionField name to declare hierarchy
*
* Those must be the `parentsRelationNames` name, that correspond to this service,
* of all children services.
*
* Eg: given the QuestionService, possible names would be:
*
* - "questions" coming from ChapterService
* - "questions" coming from QuestionService
* Whether this node is at the root of the tree (there can be multiple roots in one tree)
*/
childrenRelationNames?: string[];
root?: boolean;

/**
* Additional filters applied in the query sent by getList function
*/
filter?: QueryVariables['filter'];
filter?: ExtractVall<T>['filter'];

/**
* Key of the returned literal container models by config / service
Expand All @@ -55,12 +33,60 @@ export type NaturalHierarchicConfiguration<T extends UntypedModelService = Untyp
*
* In fact, this means isDisabled. Also applies to unselect.
*/
isSelectableCallback?: (item: any) => boolean;
isSelectableCallback?: (item: ExtractTallOne<T>) => boolean;

/**
* Functions that receives a model and returns a string for display value
*
* If missing, fallback on global `NaturalHierarchicSelectorComponent.displayWith`
*/
displayWith?: (item: any) => string;
displayWith?: (item: ExtractTallOne<T>) => string;
};

type RelationConfig<Nodes extends NodeConfig[]> = {
/**
* The parent node, eg: ChapterService
*/
parent: Nodes[number];

/**
* The child node, eg: QuestionService
*/
child: Nodes[number];

/**
* One of the keys of the `FilterGroupCondition` for the child service, to filter children by their parent(s)
*
* Those will be used directly to build filter to fetch children, so they must be
* valid API `FilterGroupCondition` keys for the given child service.
*
* Eg: given the `QuestionService`, possible names would be:
*
* - "chapter" to filter the questions by their chapter
* - "parent" to filter the questions by their parent question
*/
field: string;
};

export type NaturalHierarchicConfiguration<Nodes extends NodeConfig[]> = {
/**
* All possible nodes in the tree
*/
nodes: Nodes;

/**
* All possible relations between nodes
*/
relations: RelationConfig<Nodes>[];
};

export function nodeConfig<T extends UntypedModelService>(node: NodeConfig<T>): NodeConfig<T> {
return node;
}

export function hierarchicConfig<Nodes extends NodeConfig[]>(
nodes: Nodes,
relations: RelationConfig<Nodes>[],
): NaturalHierarchicConfiguration<Nodes> {
return {nodes: nodes, relations: relations};
}
Comment on lines +83 to +92
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On utilise 2 fonctions pour construire la config pour permettre à TypeScript de nous garantir que les noeuds utilisés dans les relations, sont bien déclarés dans la liste de noeuds.

Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {BehaviorSubject, Observable} from 'rxjs';
import {NaturalHierarchicConfiguration} from './hierarchic-configuration';
import {NaturalHierarchicConfiguration, type NodeConfig} from './hierarchic-configuration';
import {NameOrFullName} from '../../../types/types';

export type HierarchicModel = {__typename: string} & NameOrFullName;
Expand All @@ -16,7 +16,7 @@ export class ModelNode {

public constructor(
public readonly model: HierarchicModel,
public readonly config: NaturalHierarchicConfiguration,
public readonly config: NaturalHierarchicConfiguration<NodeConfig[]>,
) {}

public get children(): Observable<ModelNode[]> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {clone} from 'es-toolkit';
import {defaults} from 'es-toolkit/compat';
import {NaturalSearchFacets} from '../../search/types/facet';
import {NaturalSearchSelections} from '../../search/types/values';
import {NaturalHierarchicConfiguration} from '../classes/hierarchic-configuration';
import {NaturalHierarchicConfiguration, type NodeConfig} from '../classes/hierarchic-configuration';
import {HierarchicFiltersConfiguration} from '../classes/hierarchic-filters-configuration';
import {OrganizedModelSelection} from '../hierarchic-selector/hierarchic-selector.service';
import {MatButton} from '@angular/material/button';
Expand All @@ -15,11 +15,11 @@ export type HierarchicDialogResult = {
searchSelections?: NaturalSearchSelections | null;
};

export type HierarchicDialogConfig = {
export type HierarchicDialogConfig<Nodes extends NodeConfig[]> = {
/**
* Configuration to setup rules of hierarchy
*/
hierarchicConfig: NaturalHierarchicConfiguration[];
hierarchicConfig: NaturalHierarchicConfiguration<Nodes>;

/**
* Selected items when HierarchicComponent initializes
Expand Down Expand Up @@ -57,22 +57,22 @@ export type HierarchicDialogConfig = {
templateUrl: './hierarchic-selector-dialog.component.html',
styleUrl: './hierarchic-selector-dialog.component.scss',
})
export class NaturalHierarchicSelectorDialogComponent {
export class NaturalHierarchicSelectorDialogComponent<Nodes extends NodeConfig[]> {
private dialogRef =
inject<MatDialogRef<NaturalHierarchicSelectorDialogComponent, HierarchicDialogResult>>(MatDialogRef);
inject<MatDialogRef<NaturalHierarchicSelectorDialogComponent<Nodes>, HierarchicDialogResult>>(MatDialogRef);

/**
* Set of hierarchic configurations to pass as attribute to HierarchicComponent
*/
public config: HierarchicDialogConfig;
public config: HierarchicDialogConfig<Nodes>;

/**
* Natural search selections after initialisation
*/
public searchSelectionsOutput: NaturalSearchSelections | undefined | null;

public constructor() {
const data = inject<HierarchicDialogConfig>(MAT_DIALOG_DATA);
const data = inject<HierarchicDialogConfig<Nodes>>(MAT_DIALOG_DATA);

this.config = defaults(data, {multiple: true});
this.searchSelectionsOutput = this.config.searchSelections;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import {toGraphQLDoctrineFilter} from '../../search/classes/graphql-doctrine';
import {NaturalSearchComponent} from '../../search/search/search.component';
import {NaturalSearchFacets} from '../../search/types/facet';
import {NaturalSearchSelections} from '../../search/types/values';
import {NaturalHierarchicConfiguration} from '../classes/hierarchic-configuration';
import {NaturalHierarchicConfiguration, type NodeConfig} from '../classes/hierarchic-configuration';
import {HierarchicFiltersConfiguration} from '../classes/hierarchic-filters-configuration';
import {ModelNode} from '../classes/model-node';
import {NaturalHierarchicSelectorService, OrganizedModelSelection} from './hierarchic-selector.service';
Expand Down Expand Up @@ -59,7 +59,7 @@ import {NgTemplateOutlet} from '@angular/common';
styleUrl: './hierarchic-selector.component.scss',
providers: [NaturalHierarchicSelectorService],
})
export class NaturalHierarchicSelectorComponent implements OnInit, OnChanges {
export class NaturalHierarchicSelectorComponent<Nodes extends NodeConfig[]> implements OnInit, OnChanges {
protected readonly hierarchicSelectorService = inject(NaturalHierarchicSelectorService);

/**
Expand All @@ -70,7 +70,7 @@ export class NaturalHierarchicSelectorComponent implements OnInit, OnChanges {
/**
* Config for items and relations arrangement
*/
public readonly config = input.required<NaturalHierarchicConfiguration[]>();
public readonly config = input.required<NaturalHierarchicConfiguration<Nodes>>();

/**
* If multiple or single item selection
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import {NaturalQueryVariablesManager, QueryVariables} from '../../../classes/query-variable-manager';
import {Literal, UntypedModelService} from '../../../types/types';
import {FilterGroupCondition} from '../../search/classes/graphql-doctrine.types';
import {NaturalHierarchicConfiguration} from '../classes/hierarchic-configuration';
import {NaturalHierarchicConfiguration, type NodeConfig} from '../classes/hierarchic-configuration';
import {
HierarchicFilterConfiguration,
HierarchicFiltersConfiguration,
Expand Down Expand Up @@ -179,22 +179,22 @@
/**
* Checks that each configuration.selectableAtKey attribute is unique
*/
public validateConfiguration(configurations: NaturalHierarchicConfiguration[]): void {
const selectableAtKeyAttributes: string[] = [];
for (const config of configurations) {
if (config.selectableAtKey) {
const keyIndex = selectableAtKeyAttributes.indexOf(config.selectableAtKey);
public validateConfiguration(configurations: NaturalHierarchicConfiguration<NodeConfig[]>): void {
const selectableAtKeyAttributes = new Set<string>();
configurations.nodes.forEach(node => {
if (node.selectableAtKey) {
selectableAtKeyAttributes.add(node.selectableAtKey);
}
});

if (keyIndex === -1 && config.selectableAtKey) {
selectableAtKeyAttributes.push(config.selectableAtKey);
}
if (selectableAtKeyAttributes.size !== 1) {
console.error(
'Invalid hierarchic configuration: `selectableAtKey` attribute must exists on a least one node, and it must be unique across all nodes',
);
}

// This behavior maybe dangerous in case we re-open hierarchical selector with the last returned config
// having non-unique keys
if (keyIndex < -1) {
console.warn('Invalid hierarchic configuration : selectableAtKey attribute should be unique');
}
}
if (!configurations.nodes.find(node => node.root)) {
console.error('Invalid hierarchic configuration: `root` attribute must exists on a least one node');
}
}

Expand Down Expand Up @@ -277,7 +277,7 @@
private getConfigurationBySelectableKey(
key: NaturalHierarchicConfiguration['selectableAtKey'],
configurations: NaturalHierarchicConfiguration[],
): NaturalHierarchicConfiguration | null {

Check failure on line 280 in projects/natural/src/lib/modules/hierarchic-selector/hierarchic-selector/hierarchic-selector.service.ts

View workflow job for this annotation

GitHub Actions / lint

'any' overrides all other types in this union type

Check failure on line 280 in projects/natural/src/lib/modules/hierarchic-selector/hierarchic-selector/hierarchic-selector.service.ts

View workflow job for this annotation

GitHub Actions / lint

'any' overrides all other types in this union type
if (!configurations) {
return null;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
HierarchicDialogConfig,
NaturalHierarchicConfiguration,
NaturalHierarchicSelectorDialogService,
type NodeConfig,
OrganizedModelSelection,
} from '../../hierarchic-selector/public-api';
import {AbstractSelect} from '../abstract-select.component';
Expand Down Expand Up @@ -67,7 +68,7 @@ function defaultDisplayFn(item: Literal | null): string {
templateUrl: './select-hierarchic.component.html',
styleUrl: './select-hierarchic.component.scss',
})
export class NaturalSelectHierarchicComponent
export class NaturalSelectHierarchicComponent<Nodes extends NodeConfig[]>
extends AbstractSelect<Literal, string>
implements OnInit, ControlValueAccessor
{
Expand All @@ -83,7 +84,7 @@ export class NaturalSelectHierarchicComponent
*
* It should be an array with at least one element with `selectableAtKey` configured, otherwise the selector will never open.
*/
@Input() public config: NaturalHierarchicConfiguration[] | null = null;
@Input() public config: NaturalHierarchicConfiguration<Nodes> | null = null;

/**
* Filters formatted for hierarchic selector
Expand Down Expand Up @@ -147,7 +148,7 @@ export class NaturalSelectHierarchicComponent
selected[selectAtKey] = [this.value];
}

const hierarchicConfig: HierarchicDialogConfig = {
const hierarchicConfig: HierarchicDialogConfig<Nodes> = {
hierarchicConfig: this.config,
hierarchicSelection: selected,
hierarchicFilters: this.filters(),
Expand Down Expand Up @@ -178,6 +179,6 @@ export class NaturalSelectHierarchicComponent
}

private getSelectKey(): string | undefined {
return this.config?.find(c => !!c.selectableAtKey)?.selectableAtKey;
return this.config?.nodes.find(node => !!node.selectableAtKey)?.selectableAtKey;
}
}
64 changes: 55 additions & 9 deletions src/app/select-hierarchic/select-hierarchic.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@ import {JsonPipe} from '@angular/common';
import {Component} from '@angular/core';
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
import {MatButton} from '@angular/material/button';
import {MatFormField, MatLabel, MatHint} from '@angular/material/form-field';
import {MatFormField, MatHint, MatLabel} from '@angular/material/form-field';
import {MatInput} from '@angular/material/input';
import {NaturalHierarchicConfiguration} from '@ecodev/natural';
import {hierarchicConfig, nodeConfig} from '@ecodev/natural';
import {NaturalSelectHierarchicComponent} from '../../../projects/natural/src/lib/modules/select/select-hierarchic/select-hierarchic.component';
import {ItemService} from '../../../projects/natural/src/lib/testing/item.service';
import {AbstractSelect} from '../AbstractSelect';
import {DebugControlComponent} from '../debug-form.component';
import {FileService} from '../file/file.service';

@Component({
imports: [
Expand All @@ -27,12 +28,57 @@ import {DebugControlComponent} from '../debug-form.component';
styleUrl: './select-hierarchic.component.scss',
})
export class SelectHierarchicComponent extends AbstractSelect {
public hierarchicConfig: NaturalHierarchicConfiguration[] = [
{
service: ItemService,
parentsRelationNames: ['parent'],
childrenRelationNames: ['parent'],
selectableAtKey: 'any',
public readonly itemNode = nodeConfig({
service: ItemService,
root: true,
selectableAtKey: 'any',
});
Comment on lines +31 to +35
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

D'abord on définit les noeuds de l'arbre. La nouvelle propriété root permet de spécifier le(s) root(s) du tree.


public readonly fileNode = nodeConfig({
service: FileService,
selectableAtKey: 'any',
});

public hierarchicConfig = hierarchicConfig(
[this.itemNode],
[
{
parent: this.itemNode,
child: this.itemNode,
field: 'parent',
},
],
);
Comment on lines +42 to +51
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Puis on définit les relations entre les noeuds de l'arbre. Une relation est un parent et un child, lié par un field (comme avant, ce field est utilisé pour fabriqué le filtre pour obtenir les enfants à partir du parent).


public readonly itemForRootNode = nodeConfig({
service: ItemService,
root: true,
selectableAtKey: 'any',
});

public readonly itemForChildNode = nodeConfig({
service: ItemService,
selectableAtKey: 'any',
filter: {
conditions: [
// ...
],
},
];
});

public hierarchicConfig2 = hierarchicConfig(
[this.itemForRootNode, this.itemForChildNode],
[
{
parent: this.itemForRootNode,
child: this.itemForChildNode,
field: 'parent',
},
{
parent: this.itemForChildNode,
child: this.itemForChildNode,
field: 'parent',
},
],
);
Comment on lines +69 to +83
Copy link
Member Author

@PowerKiKi PowerKiKi Nov 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Les roots sont filtrées de façon spéciales, mais tous les enfants, récursivement, n'ont plus de filtre spécial. En fait non.

}
Loading