Skip to content
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

Convert @finos/perspective-viewer-d3fc to TypeScript #2432

Merged
merged 5 commits into from
Dec 30, 2023

Conversation

brochington
Copy link
Contributor

This PR is as a foundational first step to adding types to the perspective-viewer-d3fc package. Adding full typing to perspective-viewer-d3fc is a very large and complex task, and the resulting PR would be...massive. The aim of this PR is offer a starting ground for adding better types in the future.

In this PR:

  • All .js files in the src/js have been given a .ts extension.
  • Immediate typing issues (red squiggles) have been addressed.

Very minimal changes to the original functional code have been made, and no change in behavior should be noticeable.

@brochington brochington force-pushed the feature/typescript branch 2 times, most recently from da5c72b to 3f03d24 Compare November 15, 2023 01:32
@texodus
Copy link
Member

texodus commented Nov 21, 2023

Thanks for the PR!

Draft PRs are for work that is under development but not ready to be merged. Why is this PR in draft? Are you planning on finishing this work or ...?

Adding full typing to perspective-viewer-d3fc is a very large and complex task, and the resulting PR would be...massive.

Hence why it has not been done yet, I'm not interested in a perpetually half-typed package.

@brochington brochington force-pushed the feature/typescript branch 6 times, most recently from 94ba61e to 106c6a7 Compare December 6, 2023 16:27
@brochington brochington marked this pull request as ready for review December 6, 2023 16:27
Copy link
Member

@texodus texodus left a comment

Choose a reason for hiding this comment

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

Thanks for the PR!

Individual chart imports are broken, this definitely needs to be fixed (see notes below). This really needs a test please as well.

There is a lot of typing in this PR that isn't checked (e.g. removing or breaking it does not introduce errors) and/or has non-obvious ellision of safety in other places when written incorrectly - GetSetReturn and the interface/return type disconnect are two major ones. I am willing to merge this without complete fix for these, but I think a clear answer beyond:

Adding full typing to perspective-viewer-d3fc is a very large and complex task

What can I expect about the end state of this work? Is there a stage 2 that is going to address some of the issues below, if so which ones and when? Or is this the end state of TypeScript support in this package?

@@ -25,7 +25,10 @@ import { colorScale, setOpacity } from "../series/seriesColors";
import { colorLegend } from "../legend/legend";

function ohlcCandle(seriesCanvas) {
return function (container, settings) {
console.log("seriesCanvas");
console.dir(seriesCanvas);
Copy link
Member

Choose a reason for hiding this comment

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

💀

data: SunburstData;
}) {
const sunburstElement = d3.select(this);
const svgNode: SVGAElement = this.parentNode;
Copy link
Member

Choose a reason for hiding this comment

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

Wrong type SVGAElement

@@ -93,6 +95,10 @@ async function compile_css() {
}

async function build_all() {
// esbuild can handle typescript files, and strips out types from the output,
// but it is unable to check types, so we must run tsc as a separate step.
await exec("tsc");
Copy link
Member

Choose a reason for hiding this comment

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

The CLI output for this is ugly, lacks formatting, line break, colors, and VS Code doesn't identify the error links in the built-in terminal. If you just use execSync() instead of exec, the sub shell will get the parent process pipes.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good call! Will use execSync.

unknown
>;

export interface ChartElement extends IPerspectiveViewerElement {
Copy link
Member

Choose a reason for hiding this comment

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

Wrong base type

.attr("width", containerRect.width)
.attr("height", containerRect.height);
const containerRect: DOMRect = containerNode.getBoundingClientRect();
const handles: d3.Selection<SVGAElement, unknown, undefined, unknown> =
Copy link
Member

Choose a reason for hiding this comment

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

Wrong type SVGAElement

export type PadUnit = "percent" | "domain";
export type Pad = [number, number];

export type GetSetReturn<T, Arg, Func> = T extends undefined
Copy link
Member

Choose a reason for hiding this comment

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

This type adds value especially in the IDE, but it is excessively noisy at the function definition:

<T extends string | undefined = undefined>(
   ...args: T[]
): GetSetReturn<T, string, Obj>

and the interface

<T extends string | undefined = undefined>(
   ...args: T[]
): GetSetReturn<T, string, Obj>

and the function body (this pattern is repeated quite a lot in this file and would'v ebeen easier to type if we'd just removed args and replaced it with the known type):

if (!args.length) {
    return value as GetSetReturn<T, string, Obj>;
}

value = args[0];
return obj as GetSetReturn<T, string, Obj>;

The 2nd cast is not present in most of the code in this file, but would actually be necessary with strictNullChecks due to the values for these closure-scoped objects initializing to null.

Due to these casts (and the lack of "strict"), it is very easy to accidentally screw up the definition of one of these GetSetReturn functions as there is virtually no internal type checking on these members. Doing so introduces no errors, it just makes the type checking at the call site worse (tsc can no longer determines whether the call is a getter or setter).

This is IMO a particular pain point of this rewrite, a lot of verbosity is added here for something that we can't really enforce at compile time (with this tsconfig at least).

Copy link
Contributor

@ada-x64 ada-x64 Dec 13, 2023

Choose a reason for hiding this comment

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

d3 seems to do this with function overloads instead of a unified type, see e.g. d3-shape.d.ts. Since we're writing and immediately implementing our function, this may mean we need to rewrite the component into explicit getter/setter tuples (usually pairs, but a triple in the code below), or perhaps just add a separate .d.ts beside the original js file.
EDIT: It can be a single function implementation as long as it covers all the type constraints.

    /**
     * Returns the current data comparator, which defaults to null.
     */
    sort(): ((a: Datum, b: Datum) => number) | null;
    /**
     * Sets the data comparator to the specified function and returns this pie generator.
     *
     * If both the data comparator and the value comparator are null, then arcs are positioned in the original input order.
     * Otherwise, the data is sorted according to the data comparator, and the resulting order is used. Setting the data comparator implicitly sets the value comparator to null.
     *
     * Sorting does not affect the order of the generated arc array which is always in the same order as the input data array; it merely affects the computed angles of each arc.
     * The first arc starts at the start angle and the last arc ends at the end angle.
     *
     * @param comparator A compare function takes two arguments a and b, each elements from the input data array.
     * If the arc for a should be before the arc for b, then the comparator must return a number less than zero;
     * if the arc for a should be after the arc for b, then the comparator must return a number greater than zero;
     * returning zero means that the relative order of a and b is unspecified.
     */
    sort(comparator: (a: Datum, b: Datum) => number): this;
    /**
     * Sets the data comparator to null and returns this pie generator.
     *
     * If both the data comparator and the value comparator are null, then arcs are positioned in the original input order.
     *
     * @param comparator null, to set the pie generator to use the original input order or use the sortValues comparator, if any.
     */
    sort(comparator: null): this;

(Side note, looks like the d3 authors are on the tau side of the circle constant debate... https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/d3-shape/index.d.ts#L72)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I need to play around a little bit more with it, but so far to it looks like method overloading is a much better option than using GetSetReturn. (thanks @ada-x64!).

interface AxisFactory {
    (data: any[]): any;
    memoValue(): Axis;
    memoValue(a: Axis): this;

    excludeType(): () => AxisTypeValues;
    excludeType(nextExcludeType?: AxisTypeValues): this;
    // ... everything else
}

const AxisFactory = (settings: Settings): AxisFactory => {
      const _factory = (data): AxisFactoryContent = { ... };

      _factory.memoValue = (nextMemoValue?: Axis): Axis | AxisFactory => {
        if (!nextMemoValue) {
            return memoValue;
        }

        memoValue = nextMemoValue;
        return _factory;
    };

    _factory.excludeType = (nextExcludeType?: AxisTypeValues) => {
        if (!nextExcludeType) {
            return excludeType;
        }

        excludeType = nextExcludeType;
        return _factory;
    };
}

[key: `series-${number}`]: string;
};

export type DataRow = Record<string, any>; // might want to specify __ROW_PATH__
Copy link
Member

Choose a reason for hiding this comment

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

We should really make an effort to re-use the types from the packages where they are defined if we want to benefit from type-safety. These are defined in perspective though I do see a bug already that they don't define null and allow mixed-types per column.


export type MainValue = {
name: string;
type: string; // integer, float, string, date, datetime, boolean
Copy link
Member

Choose a reason for hiding this comment

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

" check the perspective types enum.

}

// TODO: What type should this actually be?
export type TreeData = {
Copy link
Member

Choose a reason for hiding this comment

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

TreeMap is a custom chart exclusive to Perspective, TreeData is the internal tree node type. It's not an external type it just lacks a constructor.

let radius = null;

const _sunburstSeries = (sunburstElement) => {
const _sunburstSeries: any = (sunburstElement) => {
Copy link
Member

Choose a reason for hiding this comment

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

This pattern is used quite a bit in this rewrite - casting the return type as any to avoid having to match the dynamically bound methods to the interface signature. However, this means the types of the internal object and interface may differ completely, and there are a bunch of subtle inconsistencies as a result, especially around null-ness.

Copy link
Contributor

@ada-x64 ada-x64 left a comment

Choose a reason for hiding this comment

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

Overall I'm pretty pleased with this refactor. I think there are several rough edges and places where types are still missing or improperly inferred, but I think this is worth merging as a first step.
Please take a look at the changes I've suggested. I think it would be fine to add these as additional PRs, since the goal with this PR is to at least get typescript working and out the door.


// Are these a particular type of settings?
// Question: Is there a better type def for this?
export type Settings = {
Copy link
Contributor

Choose a reason for hiding this comment

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

This is missing the columns property which sets column styles. I noticed that xy-scatter's overrideSymbols didn't have the Settings type on its settings property - this may be why.
These settings are the plugin-internal settings. Some of them get serialized, some don't. There's the EXCLUDED_SETTINGS const in plugin.ts which tells you which aren't serialized. (It's most of them.)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good catch. What do you think the type of settings.columns should be? Thanks for pointing out EXCLUDED_SETTINGS.

Copy link
Contributor

@ada-x64 ada-x64 Dec 15, 2023

Choose a reason for hiding this comment

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

Record<string, Record<string, unknown>>

This could probably be narrowed so the inner Record is an object containing the specific types of plugin configurations. For example:

type ColumnConfig = {
    symbol: Record<string, string>
}
type ColumnSettings = Record<string, ColumnConfig>

let example = {"Region": {symbol: {"West": "star"}}}

This particular configuration setting is undergoing changes atm so don't worry about fitting it too closely

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Awesome, I've updated the settings.columns type.

msMatchesSelector(selectors: string): boolean;
}

export interface Chart {
Copy link
Contributor

Choose a reason for hiding this comment

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

This could be very useful, but it's not used anywhere but ohlcCandle.
Also the proper types for the function are as follows:

    (
    	// obtained from the results of `d3.select(this._container))` in `plugin._draw` 
        // resolves to d3.Selection<HTMLElement, unknown, null, undefined>,
        container: ReturnType<typeof d3.select<HTMLElement, unknown>>,
        settings: Settings
    ): void;

The container type here would cascade down and fill out all the container types throughout the codebase. They would probably need to replace the datum parameter with whatever datum they're using, but this seems to be the general form.

EDIT: I see why this isn't used - the current code just creates a function and plops a property on it. TS can't really handle that. I know the intention is to keep the code as close to the original as possible, but I think there's a pattern we could use to integrate this. This is similar to what ohlcCandle does, and I think we can do it for the other charts as well without much change. See this playground

type ChartFactory = (
    container: {},
    settings: {},
) => void;

interface Chart {
    this: ChartFactory;
    plugin?: {
        name: string;
        // etc
    };
}

// pretend this is a module
let x = (): Chart => {
    let factory: ChartFactory = (container, settings) => {};
    let chart = factory as unknown as Chart;
    chart.plugin = {name: "hey"};
    return chart;
}

console.log(x(), x().plugin);

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good catch on the container arg type! I've updated the Chart type and chart functions.

The ChartFactory/Chart pattern described above could be done, but I'm not 100% it needs to be (at least at the moment). There is some checking of the chart types where the constructors are being used:

// src/ts/charts/charts.ts
const chartClasses: Chart[] = [
    barChart,
    columnChart,
    lineChart,
    xyLine,
    areaChart,
    yScatter,
    xyScatter,
    ohlc,
    candlestick,
    treemap,
    sunburst,
    heatmap,
];

So even though the type isn't defined on the functions themselves, they are "checked" on the chartClasses array type constraint. Is this enough for now? I dunno, curious what others think.

Copy link
Member

Choose a reason for hiding this comment

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

Is this enough for now? I dunno, curious what others think.

As I alluded to in the first comment, this work needs to eventually be all-or-nothing. Whether this is "enough" is the wrong question to ask - the real question is, what standard do you want to set for the developers who contribute after you? If someone else changes this code in ways that reduces type safety but the contributor thinks its enough and you don't, what do I merge? For a project of this scale, the only fair answer is that we encode our standards in tests, lint, CI, type-checking as much as we possibly can.

As I mentioned in my subling comment wrt to the constructor type of SunburstSeries, while this PR adds a lot to the present developer experience, I am concerned that it is highly susceptible to bit-rot. Take this thread for example - Chart constraint only produces errors on this line, not in the definition of e.g. a mis-written barChart function; within that file, you can rename plugin (as it is marked nullable ? in the type), add mystery fields, and do all sort of "broken" things without causing an error.

A good type system needs to prevent future developers from modifying the code incorrectly, not just provide intellisense for what is already written.

// tsc errors tend to get buried when running multiple package builds. If
// the perspective-viewer-d3fc build fails, then plugins will not be present
// when running tests, leading to a large number of tests failing, but without
// a great indication of why.
Copy link
Member

Choose a reason for hiding this comment

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

Ok but ...then why catch the error? Printing and exiting with code 1 is the default behavior for an unhandled exception in this function. This only sticks out to me because we do this verbatim in perspecitve-viewer without issue.

// type for now because if it was added to DataRow, the resulting type for properties
// would be a union of string | boolean | Date | number | (string | number)[], which is
// not specific enough.
export type RowPath = (string | number)[];
Copy link
Member

@texodus texodus Dec 30, 2023

Choose a reason for hiding this comment

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

These can also be Date or boolean

Copy link
Member

@texodus texodus left a comment

Choose a reason for hiding this comment

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

Looks good!

Much better with the removal of GetSetReturn :)

@texodus texodus merged commit 0700210 into finos:master Dec 30, 2023
12 checks passed
@texodus texodus changed the title Add starting support for Typescript in perspective-viewer-d3fc package Convert @finos/perspective-viewer-d3fc to TypeScript Dec 30, 2023
@texodus texodus added the internal Internal refactoring and code quality improvement label Dec 30, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
internal Internal refactoring and code quality improvement
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants