-
-
Notifications
You must be signed in to change notification settings - Fork 3.1k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(angular-table): Added support for custom component renderer with signal #5576
feat(angular-table): Added support for custom component renderer with signal #5576
Conversation
☁️ Nx Cloud ReportCI is running/has finished running commands for commit 72ac9ca. As they complete they will appear below. Click to see the status, the terminal output, and the build insights. 📂 See all runs for this CI Pipeline Execution ✅ Successfully ran 1 targetSent with 💌 from NxCloud. |
@KevinVandy @riccardoperra this change allows the following: Defining columns using the Component types directly. readonly columns: ColumnDef<Person>[] = [
{
id: 'select',
header: () => TableHeadSelectionComponent<Person>,
cell: () => TableRowSelectionComponent<Person>,
}, Using input signals in the Component. @Component({
template: `
<input
type="checkbox"
[checked]="table().getIsAllRowsSelected()"
[indeterminate]="table().getIsSomeRowsSelected()"
(change)="table().toggleAllRowsSelected()"
/>
`,
host: {
class: 'px-1 block',
},
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TableHeadSelectionComponent<T> {
// Your component should also reflect the fields you use as props in flexRenderer directive.
// Define the fields as input you want to use in your component
// ie. In this case, you are passing HeaderContext object as props in flexRenderer directive.
// You can define and use the table field, which is defined in HeaderContext.
// Please take note that only signal based input is supported.
//column = input.required<Column<T, unknown>>()
//header = input.required<Header<T, unknown>>()
table = input.required<Table<T>>()
} |
Providers allows to inject the context and use the variables immediately (you don't need to wait for onInit+ or using effects); I have some doubts about relying only on inputs:
Currently
|
I think you need to revisit this implementation. If const componentInjector = Injector.create({
parent: injector ?? this.injector,
providers: [{ provide: FlexRenderComponentProps, useValue: proxy }],
})
const componentRef = this.viewContainerRef.createComponent(component, {
injector: componentInjector,
})
for (const prop in inputs) {
if (componentRef.instance?.hasOwnProperty(prop)) {
componentRef.setInput(prop, inputs[prop])
}
}
This should not be an issue using Signal. As you may know, the framework handles changeDetection automatically when signal changes. Using signal in
The
Without using Below is how it is used in {
id: 'select',
header: ({ table }) => (
<IndeterminateCheckbox
{...{
checked: table.getIsAllRowsSelected(),
indeterminate: table.getIsSomeRowsSelected(),
onChange: table.getToggleAllRowsSelectedHandler(),
}}
/>
),
cell: ({ row }) => (
<div className="px-1">
<IndeterminateCheckbox
{...{
checked: row.getIsSelected(),
disabled: !row.getCanSelect(),
indeterminate: row.getIsSomeSelected(),
onChange: row.getToggleSelectedHandler(),
}}
/>
</div>
),
}, |
Providers are immediately available, the proxy is needed in order to always get the
Looking to the source code, if the reference value doesn't change, the component is not marked as dirty https://github.com/angular/angular/blob/825023801baf8dbd0bd87b7ec46a65e869e08adb/packages/core/src/render3/component_ref.ts#L464
If table doesn't change and we don't handle change detection in that case, calling
All other frameworks allows to define the component template inside the ts code, then you can set the input as you want. Doing that in angular will force consumers to create a component with exactly that input names. |
what i meant was the component is marked dirty right after
what do you mean by this? It seems like you're implying that the framework is maintaining multiple versions of the value? I think this is incorrect. If the object reference did not change, but the value of the object did (this means the object is mutated), change detection will not happen (This is the same for The table doesn't change but its states will. When table state changes, Angular table handles that changes, which updates the signal values, which automatically schedule change detection.
Isn't this better? The consumer knows what type of Example below: @Component({
template: `
<input
type="checkbox"
[checked]="context.row.getIsSelected()"
(change)="context.row.getToggleSelectedHandler()($event)"
/>
`,
host: {
class: 'px-1 block',
},
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TableRowSelectionComponent<T> {
context = injectFlexRenderContext<CellContext<T, unknown>>()
}
|
I wrote quickly and explained myself poorly. @Component({
template: `
<input
type="checkbox"
[checked]="table().getIsAllRowsSelected()"
[indeterminate]="table().getIsSomeRowsSelected()"
(change)="table().toggleAllRowsSelected()"
/>
`,
host: {
class: 'px-1 block',
},
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TableHeadSelectionComponent<T> {
table = input(); Some premises:
Suppose that we have a table signal that we try to set with If the view is not marked as dirty, our In the current implementation, this is now working because we have the This is what happens if you remove that logic into the flex render and relies only on tanstack-table-without-ngdocheck.mp4table/packages/angular-table/src/flex-render.ts Lines 59 to 65 in a4bd09a
Anyway I think this can be moved into an ngOnChanges, since I think we want to do that only if the ngOnChanges(changes) {
// mark the current component view as dirty, if present
componentRef?.injector.get(ChangeDetectorRef).markForCheck();
if (!changes.content) {
// if content doesn't change we don't need to render again from scratch
return
}
vcr.clear();
// rendering logic
render(this.content, this.props);
}
I am ok with you about that without injectContext and directly passing a component will be easier for consumers, but in my opinion a very important use case is that the user should be able to also set the component as he wants, and must be able to pass other inputs as well like other frameworks adapter does. With a "wrapper" you could do something like that new FlexRenderComponent(MyComponent, { onClick: ..., isDisabled: this.someActionIsLoading...} ) What we could do to simplify this is maybe do something like hostDirectives, where you pass an object, but I think we miss the type hints, which you can keep with the current FlexRenderComponent () => {
return {
component: MyComponent,
inputs: {}
}
} |
You're right, it only needs the row object in our example. But you can't know if the consumer need to use them for it's use cases. With the inject approach you'll get the same context as the column definition. Consider that these properties are always present when you define the cell/footer/header def |
Yes, but custom component renderer implementation is using
I was having this assumption that we converted all members/object in the table to @Component({
template: `
<input
type="checkbox"
[checked]="row().getIsSelected()"
(change)="row().getToggleSelectedHandler()($event)"
/>
`,
host: {
class: 'px-1 block',
},
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TableRowSelectionComponent<T> {
row = input.required<Row<T>>()
}
I'm ok with this having a wrapper that injects customs inputs. But are we doing it in other frameworks, like react? I didn't see any examples. And also, are there any such cases where we need custom inputs? Please take note that both directives and components are handled by compiler differently if |
This is why i explained in my example below that consumer can define all the objects the components need from the @Component({
template: `
<input
type="checkbox"
[checked]="table().getIsAllRowsSelected()"
[indeterminate]="table().getIsSomeRowsSelected()"
(change)="table().toggleAllRowsSelected()"
/>
`,
host: {
class: 'px-1 block',
},
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TableHeadSelectionComponent<T> {
// Your component should also reflect the fields you use as props in flexRenderer directive.
// Define the fields as input you want to use in your component
// ie. In this case, you are passing HeaderContext object as props in flexRenderer directive.
// You can define and use the table field, which is defined in HeaderContext.
// Please take note that only signal based input is supported.
//column = input.required<Column<T, unknown>>()
//header = input.required<Header<T, unknown>>()
table = input.required<Table<T>>()
} |
This is an example of material-react-table which has defined a custom input to the selection component. They've could also defined Unfortunately what's missing in angular is defining an inline template per cell like the other framework does |
It seems like the only way to pass values to the component is still through |
Hey fyi. Still following this PR. PRs into the main branch need to be non breaking. However, we have a newly created alpha branch for v9 that can accept well documented breaking changes. If we deem any of these proposals good, but breaking, we can direct a PR there. We can also make the min angular version 18 there. |
@KevinVandy this is non breaking change. This will allow consumer an option to create a signal component to be rendered. Currently, in order for component to be rendered in the table, it has to use |
@merto20 My bad 🤦 All of this time I was thinking the Anyway, I may have some suggestion: for (const prop in this.props) {
// Only signal based input can be added here
- if (componentRef.instance?.hasOwnProperty(prop)) {
+ if (componentRef.instance?.hasOwnProperty(prop) && isSignal(componentRef.instance[prop])) {
componentRef.setInput(prop, this.props[prop])
}
} I think here, in both renderComponent and renderCustomComponent, we can also invoke Another alternative, but maybe I shouldn't even suggest it, is to use some angular private constants which allows us to set even "non signal" inputs. But potentially this is unsafe. const componentDef: ɵComponentDef<unknown> | undefined = Reflect.get(
componentRef.componentType,
ɵNG_COMP_DEF // -> this is ɵcmp symbol
)
if (!componentDef) {
// this is not a component
throw new Error()
}
if (componentDef.hasOwnProperty('inputs')) {
const declaredInputs = Object.keys(componentDef.inputs) // here we have both keys of signal input and decorator-based inputs
} |
@riccardoperra ohh I thought we're discussing the other PR here. Anyway, we can discuss more about the other PR when we need to support signal based directive. |
@merto20 Can we add a test to the flexrender.test.ts in order to verify this new behavior? |
@riccardoperra how did you run your test locally? For me, it's always stuck on |
@merto20 I mostly use Webstorm interface to run tests Anyway, running the command manually works fine for me. Are you running the command from the angular table package? |
I might not be able to check out and review this for a few days. Continuing to provide code review from others like @riccardoperra is appreciated. |
@riccardoperra I commented the test I created for custom component. Unfortunately, signal inputs are not recognized as component inputs. |
We should stay with vitest now. Anyway I think you can do a wrapper component and put in its template your “fake” one passing the data via input binding |
I'm not planning to change vitest, just want to confirm if it is because of vitest. |
wrapper component is fine in my opinion, it's a unit test independent by the table itself. Anyway I prefer to avoid commenting test, just use skip method |
@KevinVandy this pr should be fine, I think we can merge it |
@merto20 I'd like to see the relevant flex render docs get updated along-side this PR |
@KevinVandy I created this PR #5590. Pls check. Thanks. |
This support provides option to simplify the component creation process. By allowing consumers to use signal inputs and component types directly.