Skip to content

Commit

Permalink
feat: make default value optional for ValueSignal (#3160)
Browse files Browse the repository at this point in the history
* feat!: make default value optional for ValueSignal

Fixes #2746

* add support for custom object types

* remove comment out codes

* fix bug and add tests

* Apply suggestions from code review
---------

Co-authored-by: Luciano Vernaschi <[email protected]>
  • Loading branch information
taefi and cromoteca authored Jan 28, 2025
1 parent 6de563d commit da0bb75
Show file tree
Hide file tree
Showing 6 changed files with 710 additions and 69 deletions.
195 changes: 168 additions & 27 deletions packages/ts/generator-plugin-signals/src/SignalProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@ const signals = ['NumberSignal', 'ValueSignal', 'ListSignal'];
const genericSignals = ['ValueSignal', 'ListSignal'];
const collectionSignals = ['ListSignal'];

const primitiveModels = Object.freeze(
new Map<ts.SyntaxKind, string>([
[ts.SyntaxKind.StringKeyword, 'StringModel'],
[ts.SyntaxKind.NumberKeyword, 'NumberModel'],
[ts.SyntaxKind.BooleanKeyword, 'BooleanModel'],
[ts.SyntaxKind.ArrayType, 'ArrayModel'],
]),
);

export default class SignalProcessor {
readonly #dependencyManager: DependencyManager;
readonly #owner: Plugin;
Expand Down Expand Up @@ -48,27 +57,21 @@ export default class SignalProcessor {
transform((tsNode) => {
if (ts.isFunctionDeclaration(tsNode) && tsNode.name && this.#methods.has(tsNode.name.text)) {
const signalId = this.#replaceSignalImport(tsNode);
let initialValue: ts.Expression = signalId.text.startsWith('NumberSignal')
? ts.factory.createNumericLiteral('0')
: ts.factory.createIdentifier('undefined');
const filteredParams = tsNode.parameters.filter(
(p) => !p.type || !ts.isTypeReferenceNode(p.type) || p.type.typeName !== initTypeId,
);
// `filteredParams` can be altered after, need to store the param names now
const paramNames = filteredParams.map((p) => (p.name as ts.Identifier).text).join(', ');
const isCollectionSignal = collectionSignals.includes(signalId.text);
let genericReturnType;
if (genericSignals.includes(signalId.text)) {
genericReturnType = (tsNode.type as ts.TypeReferenceNode).typeArguments![0];
if (!isCollectionSignal) {
const defaultValueType = SignalProcessor.#getDefaultValueType(genericReturnType);
if (defaultValueType) {
const { alias, param } = SignalProcessor.#createDefaultValueParameter(defaultValueType);
initialValue = alias;
filteredParams.push(param);
}
}

const { defaultValueExpression, defaultValueParam, genericReturnType } = this.#createDefaultValue(
signalId,
tsNode,
);
if (defaultValueParam) {
filteredParams.push(defaultValueParam);
}

const returnType = genericReturnType ?? signalId;
if (filteredParams.length > 0) {
functionParams.set(tsNode.name.text, filteredParams);
Expand All @@ -83,7 +86,9 @@ export default class SignalProcessor {
transform((node) => (ts.isIdentifier(node) && node.text === SIGNAL ? signalId : node)),
transform((node) => (ts.isIdentifier(node) && node.text === RETURN_TYPE ? returnType : node)),
transform((node) => (ts.isIdentifier(node) && node.text === CONNECT_CLIENT ? connectClientId : node)),
transform((node) => (ts.isIdentifier(node) && node.text === INITIAL_VALUE ? initialValue : node)),
transform((node) =>
ts.isIdentifier(node) && node.text === INITIAL_VALUE ? defaultValueExpression : node,
),
],
);
}
Expand Down Expand Up @@ -135,6 +140,50 @@ export default class SignalProcessor {
);
}

#createDefaultValue(signalId: ts.Identifier, functionDeclaration: FunctionDeclaration) {
const defaultValue: {
defaultValueExpression: ts.Expression | ts.Identifier;
defaultValueParam: ts.ParameterDeclaration | undefined;
genericReturnType: ts.TypeNode | undefined;
} = {
defaultValueExpression: signalId.text.startsWith('NumberSignal')
? ts.factory.createNumericLiteral('0')
: ts.factory.createIdentifier('undefined'),
defaultValueParam: undefined,
genericReturnType: undefined,
};

if (!genericSignals.includes(signalId.text)) {
return defaultValue;
}

defaultValue.genericReturnType = (functionDeclaration.type as ts.TypeReferenceNode).typeArguments![0];

if (collectionSignals.includes(signalId.text)) {
return defaultValue;
}

const defaultValueType = SignalProcessor.#getDefaultValueType(defaultValue.genericReturnType);
if (!defaultValueType) {
return defaultValue;
}

defaultValue.defaultValueParam = SignalProcessor.#createDefaultValueParameter(defaultValueType);
const emptyValueExpression = this.#createEmptyValueExpression(defaultValueType);

defaultValue.defaultValueExpression = ts.factory.createBinaryExpression(
ts.factory.createPropertyAccessChain(
ts.factory.createIdentifier('options'),
ts.factory.createToken(ts.SyntaxKind.QuestionDotToken),
ts.factory.createIdentifier('defaultValue'),
),
ts.factory.createToken(ts.SyntaxKind.QuestionQuestionToken),
emptyValueExpression,
);

return defaultValue;
}

static #getDefaultValueType(node: ts.Node) {
if (
ts.isUnionTypeNode(node) &&
Expand All @@ -145,23 +194,115 @@ export default class SignalProcessor {
) {
return node.types[0].typeArguments[0];
}

return undefined;
}

static #createDefaultValueParameter(returnType: ts.TypeNode) {
const alias = createFullyUniqueIdentifier('defaultValue');
const bindingPattern = ts.factory.createObjectBindingPattern([
ts.factory.createBindingElement(undefined, ts.factory.createIdentifier('defaultValue'), alias, undefined),
]);
static #createDefaultValueParameter(defaultValueType: ts.TypeNode) {
const paramType = ts.factory.createTypeLiteralNode([
ts.factory.createPropertySignature(undefined, ts.factory.createIdentifier('defaultValue'), undefined, returnType),
ts.factory.createPropertySignature(
undefined,
ts.factory.createIdentifier('defaultValue'),
undefined,
defaultValueType,
),
]);
// Return both the alias and the full parameter
return {
alias,
param: ts.factory.createParameterDeclaration(undefined, undefined, bindingPattern, undefined, paramType),
};

return ts.factory.createParameterDeclaration(
undefined,
undefined,
'options',
ts.factory.createToken(ts.SyntaxKind.QuestionToken),
paramType,
);
}

static #isDefaultValueTypeNullable(defaultValueType: ts.TypeNode) {
return (
ts.isUnionTypeNode(defaultValueType) &&
defaultValueType.types.length &&
defaultValueType.types.length > 1 &&
defaultValueType.types.map((t) => t.kind).includes(ts.SyntaxKind.UndefinedKeyword)
);
}

#createEmptyValueExpression(defaultValueType: ts.UnionTypeNode) {
if (SignalProcessor.#isDefaultValueTypeNullable(defaultValueType)) {
return ts.factory.createIdentifier('undefined');
}
const importedModelUniqueId = this.#determineModelImportUniqueIdentifier(defaultValueType);
return ts.factory.createCallExpression(
ts.factory.createPropertyAccessExpression(importedModelUniqueId, 'createEmptyValue'),
undefined,
[],
);
}

#determineModelImportUniqueIdentifier(returnTypeNode: ts.UnionTypeNode) {
let modelName = primitiveModels.get(returnTypeNode.types[0].kind);
let entityName;
if (modelName === undefined) {
const { entityName: e, modelName: m } = SignalProcessor.#extractModelNameFromTypeNode(returnTypeNode);
modelName = m;
entityName = e;
}
const modelImportUniqueId =
this.#getExistingEntityModelUniqueIdentifier(modelName) ?? createFullyUniqueIdentifier(modelName);

this.#addModelImport(entityName, modelName, modelImportUniqueId);
return modelImportUniqueId;
}

static #extractModelNameFromTypeNode(returnTypeNode: ts.UnionTypeNode) {
if (ts.isTypeReferenceNode(returnTypeNode.types[0])) {
const typeIdentifier = returnTypeNode.types[0].typeName;
if (ts.isIdentifier(typeIdentifier)) {
const entityName = typeIdentifier.text;
const modelName = `${entityName}Model`;
return { entityName, modelName };
}
}
throw new Error('Unsupported type reference node');
}

#getExistingEntityModelUniqueIdentifier(modelName: string) {
const { imports } = this.#dependencyManager;
return (
imports.named.getIdentifier('@vaadin/hilla-lit-form', modelName) ??
imports.default.iter().find(([path]) => path.endsWith(`/${modelName}.js`))?.[1]
);
}

#addModelImport(
entityName: string | undefined,
modelName: string | undefined,
modelNameUniqueId: ts.Identifier | undefined,
) {
if (modelName) {
if (primitiveModels.values().find((primitiveModel) => primitiveModel === modelName)) {
const { imports } = this.#dependencyManager;
const importedModel = imports.named.getIdentifier('@vaadin/hilla-lit-form', modelName);
if (importedModel === undefined) {
imports.named.add('@vaadin/hilla-lit-form', modelName, false, modelNameUniqueId);
}
} else {
this.#addObjectModelImport(entityName!, modelName, modelNameUniqueId!);
}
}
}

#addObjectModelImport(entityName: string, modelName: string, modelNameUniqueId: ts.Identifier) {
const { imports } = this.#dependencyManager;
const entityImport = imports.default
.iter()
.map(([path]) => path)
.find((path) => path.startsWith('./') && path.endsWith(`/${entityName}.js`));
if (entityImport) {
const entityModelImportPath = entityImport.replace(`/${entityName}.js`, `/${modelName}.js`);
const importedModel = imports.default.paths().find((path) => path === entityModelImportPath);
if (importedModel === undefined) {
imports.default.add(entityModelImportPath, modelName, false, modelNameUniqueId);
}
}
}

#replaceSignalImport(method: FunctionDeclaration): Identifier {
Expand Down
14 changes: 14 additions & 0 deletions packages/ts/generator-plugin-signals/test/SignalsEndpoints.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,5 +86,19 @@ describe('SignalsPlugin', () => {
import.meta.url,
);
});

it('correctly generates service with automatic default values for value signals of primitive types', async () => {
const generator = new Generator([BackbonePlugin, SignalsPlugin], {
logger: new LoggerFactory({ name: 'signals-plugin-test', verbose: true }),
});
const input = await readFile(new URL('./hilla-openapi-default-value.json', import.meta.url), 'utf8');
const files = await generator.process(input);

const generatedValueSignalService = files.find((f) => f.name === 'PrimitiveTypeValueSignalService.ts')!;
await expect(await generatedValueSignalService.text()).toMatchSnapshot(
`PrimitiveTypeValueSignalService.snap.ts`,
import.meta.url,
);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { ArrayModel as ArrayModel_1, BooleanModel as BooleanModel_1, NumberModel as NumberModel_1, StringModel as StringModel_1 } from "@vaadin/hilla-lit-form";
import { ValueSignal as ValueSignal_1 } from "@vaadin/hilla-react-signals";
import client_1 from "./connect-client.default.js";
function anotherStringValueSignal_1(options?: {
defaultValue: string;
}): ValueSignal_1<string> {
return new ValueSignal_1(options?.defaultValue ?? StringModel_1.createEmptyValue(), { client: client_1, endpoint: "PrimitiveTypeValueSignalService", method: "anotherStringValueSignal" });
}
function booleanValueSignal_1(options?: {
defaultValue: boolean;
}): ValueSignal_1<boolean> {
return new ValueSignal_1(options?.defaultValue ?? BooleanModel_1.createEmptyValue(), { client: client_1, endpoint: "PrimitiveTypeValueSignalService", method: "booleanValueSignal" });
}
function booleanValueSignalNullable_1(options?: {
defaultValue: boolean | undefined;
}): ValueSignal_1<boolean | undefined> {
return new ValueSignal_1(options?.defaultValue ?? undefined, { client: client_1, endpoint: "PrimitiveTypeValueSignalService", method: "booleanValueSignalNullable" });
}
function doubleValueSignal_1(options?: {
defaultValue: number;
}): ValueSignal_1<number> {
return new ValueSignal_1(options?.defaultValue ?? NumberModel_1.createEmptyValue(), { client: client_1, endpoint: "PrimitiveTypeValueSignalService", method: "doubleValueSignal" });
}
function doubleValueSignalNullable_1(options?: {
defaultValue: number | undefined;
}): ValueSignal_1<number | undefined> {
return new ValueSignal_1(options?.defaultValue ?? undefined, { client: client_1, endpoint: "PrimitiveTypeValueSignalService", method: "doubleValueSignalNullable" });
}
function stringArrayValueSignal_1(options?: {
defaultValue: Array<string>;
}): ValueSignal_1<Array<string>> {
return new ValueSignal_1(options?.defaultValue ?? ArrayModel_1.createEmptyValue(), { client: client_1, endpoint: "PrimitiveTypeValueSignalService", method: "stringArrayValueSignal" });
}
function stringArrayValueSignalNullable_1(options?: {
defaultValue: Array<string | undefined>;
}): ValueSignal_1<Array<string | undefined>> {
return new ValueSignal_1(options?.defaultValue ?? ArrayModel_1.createEmptyValue(), { client: client_1, endpoint: "PrimitiveTypeValueSignalService", method: "stringArrayValueSignalNullable" });
}
function stringValueSignal_1(options?: {
defaultValue: string;
}): ValueSignal_1<string> {
return new ValueSignal_1(options?.defaultValue ?? StringModel_1.createEmptyValue(), { client: client_1, endpoint: "PrimitiveTypeValueSignalService", method: "stringValueSignal" });
}
function stringValueSignalNullable_1(options?: {
defaultValue: string | undefined;
}): ValueSignal_1<string | undefined> {
return new ValueSignal_1(options?.defaultValue ?? undefined, { client: client_1, endpoint: "PrimitiveTypeValueSignalService", method: "stringValueSignalNullable" });
}
export { anotherStringValueSignal_1 as anotherStringValueSignal, booleanValueSignal_1 as booleanValueSignal, booleanValueSignalNullable_1 as booleanValueSignalNullable, doubleValueSignal_1 as doubleValueSignal, doubleValueSignalNullable_1 as doubleValueSignalNullable, stringArrayValueSignal_1 as stringArrayValueSignal, stringArrayValueSignalNullable_1 as stringArrayValueSignalNullable, stringValueSignal_1 as stringValueSignal, stringValueSignalNullable_1 as stringValueSignalNullable };
Original file line number Diff line number Diff line change
@@ -1,29 +1,36 @@
import { EndpointRequestInit as EndpointRequestInit_1 } from "@vaadin/hilla-frontend";
import { ArrayModel as ArrayModel_1 } from "@vaadin/hilla-lit-form";
import { ListSignal as ListSignal_1, ValueSignal as ValueSignal_1 } from "@vaadin/hilla-react-signals";
import type Person_1 from "./com/github/taefi/data/Person.js";
import PersonModel_1 from "./com/github/taefi/data/PersonModel.js";
import client_1 from "./connect-client.default.js";
async function getPerson_1(init?: EndpointRequestInit_1): Promise<Person_1 | undefined> { return client_1.call("PersonService", "getPerson", {}, init); }
function personArraySignal_1(options?: {
defaultValue: Array<Person_1>;
}): ValueSignal_1<Array<Person_1>> {
return new ValueSignal_1(options?.defaultValue ?? ArrayModel_1.createEmptyValue(), { client: client_1, endpoint: "PersonService", method: "personArraySignal" });
}
function personListSignal_1(): ListSignal_1<Person_1> {
return new ListSignal_1({ client: client_1, endpoint: "PersonService", method: "personListSignal" });
}
function personSignal_1({ defaultValue: defaultValue_1 }: {
function personSignalNotNull_1(options?: {
defaultValue: Person_1;
}): ValueSignal_1<Person_1> {
return new ValueSignal_1(options?.defaultValue ?? PersonModel_1.createEmptyValue(), { client: client_1, endpoint: "PersonService", method: "personSignalNotNull" });
}
function personSignalNullable_1(isAdult: boolean, options?: {
defaultValue: Person_1 | undefined;
}): ValueSignal_1<Person_1 | undefined> {
return new ValueSignal_1(defaultValue_1, { client: client_1, endpoint: "PersonService", method: "personSignal" });
return new ValueSignal_1(options?.defaultValue ?? undefined, { client: client_1, endpoint: "PersonService", method: "personSignalNullable", params: { isAdult } });
}
function personSignalWithParams_1(dummyBoolean: boolean, dummyString: string | undefined, { defaultValue: defaultValue_2 }: {
function personSignalWithParams_1(dummyBoolean: boolean, dummyString: string | undefined, options?: {
defaultValue: Person_1 | undefined;
}): ValueSignal_1<Person_1 | undefined> {
return new ValueSignal_1(defaultValue_2, { client: client_1, endpoint: "PersonService", method: "personSignalWithParams", params: { dummyBoolean, dummyString } });
}
function personSignalNonNull_1({ defaultValue: defaultValue_3 }: {
defaultValue: Person_1;
}): ValueSignal_1<Person_1> {
return new ValueSignal_1(defaultValue_3, { client: client_1, endpoint: "PersonService", method: "personSignalNonNull" });
return new ValueSignal_1(options?.defaultValue ?? undefined, { client: client_1, endpoint: "PersonService", method: "personSignalWithParams", params: { dummyBoolean, dummyString } });
}
function personSignalNonNullWithParams_1(dummyBoolean: boolean, dummyString: string | undefined, { defaultValue: defaultValue_4 }: {
function personSignalNonNullWithParams_1(dummyBoolean: boolean, dummyString: string | undefined, options?: {
defaultValue: Person_1;
}): ValueSignal_1<Person_1> {
return new ValueSignal_1(defaultValue_4, { client: client_1, endpoint: "PersonService", method: "personSignalNonNullWithParams", params: { dummyBoolean, dummyString } });
return new ValueSignal_1(options?.defaultValue ?? PersonModel_1.createEmptyValue(), { client: client_1, endpoint: "PersonService", method: "personSignalNonNullWithParams", params: { dummyBoolean, dummyString } });
}
export { getPerson_1 as getPerson, personListSignal_1 as personListSignal, personSignal_1 as personSignal, personSignalNonNull_1 as personSignalNonNull, personSignalNonNullWithParams_1 as personSignalNonNullWithParams, personSignalWithParams_1 as personSignalWithParams };
export { getPerson_1 as getPerson, personArraySignal_1 as personArraySignal, personListSignal_1 as personListSignal, personSignalNonNullWithParams_1 as personSignalNonNullWithParams, personSignalNotNull_1 as personSignalNotNull, personSignalNullable_1 as personSignalNullable, personSignalWithParams_1 as personSignalWithParams };
Loading

0 comments on commit da0bb75

Please sign in to comment.