From 75dbd5aca5ec6b4a4387785ea5b3c0457f6fe867 Mon Sep 17 00:00:00 2001 From: Dmytro Borzenko <51016717+dmitriy-borzenko@users.noreply.github.com> Date: Thu, 6 Jul 2023 12:49:24 +0300 Subject: [PATCH] Kamu 168 set transform add event from UI (#106) * Add SetTransformComponent. * Add search and fix qraphql schema. * Add material-tree module. * Add schema for input datasets. * Add templates for the queries. * Add the ability to edit queries and to save event. * Add navigation for input datasets. * Add unit tests. * Add owner name to the input datasets tree node. --- CHANGELOG.md | 1 + resources/schema.graphql | 200 +++++++++++- src/app/api/dataset.api.ts | 18 ++ src/app/api/engine.api.spec.ts | 20 ++ src/app/api/engine.api.ts | 21 ++ src/app/api/gql/dataset-data-sql-run.graphql | 2 +- src/app/api/gql/dataset-schema.graphql | 13 + src/app/api/gql/engines.graphql | 9 + .../fragment-dataset-overview.graphql | 3 + src/app/api/kamu.graphql.interface.ts | 302 +++++++++++++++++- src/app/app-routing.module.ts | 7 + src/app/app.module.ts | 2 - src/app/common/app.helpers.ts | 12 + src/app/common/base-yaml-event.service.ts | 89 ++++++ src/app/common/data.helpers.ts | 14 +- .../sql-query-viewer.component.ts | 2 - .../event-details/config-editor.events.ts | 7 +- .../dataset-create.component.html | 2 +- .../dataset-create.component.ts | 2 +- .../data-component/data-component.html | 12 +- .../data-component/data-component.ts | 10 +- .../add-polling-source.component.html | 5 +- .../add-polling-source.component.spec.ts | 36 ++- .../add-polling-source.component.ts | 26 +- .../edit-polling-source.service.spec.ts | 15 +- .../edit-polling-source.service.ts | 70 +--- .../process-form.service.ts | 5 +- .../final-yaml-modal.component.html | 3 +- .../final-yaml-modal.component.ts | 6 +- .../engine-section.component.html | 30 ++ .../engine-section.component.sass | 3 + .../engine-section.component.spec.ts | 67 ++++ .../engine-section.component.ts | 74 +++++ .../queries-section.component.html | 76 +++++ .../queries-section.component.sass | 24 ++ .../queries-section.component.spec.ts | 63 ++++ .../queries-section.component.ts | 51 +++ .../search-section.component.html | 107 +++++++ .../search-section.component.sass | 38 +++ .../search-section.component.spec.ts | 161 ++++++++++ .../search-section.component.ts | 128 ++++++++ .../edit-set-transform..service.spec.ts | 70 ++++ .../edit-set-transform..service.ts | 57 ++++ .../components/set-transform/mock.data.ts | 108 +++++++ .../set-transform.component.html | 39 +++ .../set-transform.component.sass | 20 ++ .../set-transform.component.spec.ts | 146 +++++++++ .../set-transform/set-transform.component.ts | 203 ++++++++++++ .../set-transform/set-transform.types.ts | 30 ++ .../stepper-navigation.component.ts | 6 +- .../metadata-component/metadata-component.ts | 16 +- .../metadata.component.html | 21 +- .../overview-component.html | 14 + .../overview-component/overview-component.ts | 8 + src/app/dataset-view/dataset.module.ts | 16 +- src/app/dataset-view/dataset.service.ts | 19 +- .../dataset.subscriptions.interface.ts | 2 + src/app/project-links.ts | 1 + src/app/services/engine.service.spec.ts | 20 ++ src/app/services/engine.service.ts | 15 + src/app/services/navigation.service.ts | 10 + .../services/templates-yaml-events.service.ts | 16 +- src/assets/images/datafusion-logo.png | Bin 0 -> 17551 bytes src/styles.sass | 29 ++ 64 files changed, 2444 insertions(+), 158 deletions(-) create mode 100644 src/app/api/engine.api.spec.ts create mode 100644 src/app/api/engine.api.ts create mode 100644 src/app/api/gql/dataset-schema.graphql create mode 100644 src/app/api/gql/engines.graphql create mode 100644 src/app/common/base-yaml-event.service.ts create mode 100644 src/app/dataset-view/additional-components/metadata-component/components/set-transform/components/engine-section/engine-section.component.html create mode 100644 src/app/dataset-view/additional-components/metadata-component/components/set-transform/components/engine-section/engine-section.component.sass create mode 100644 src/app/dataset-view/additional-components/metadata-component/components/set-transform/components/engine-section/engine-section.component.spec.ts create mode 100644 src/app/dataset-view/additional-components/metadata-component/components/set-transform/components/engine-section/engine-section.component.ts create mode 100644 src/app/dataset-view/additional-components/metadata-component/components/set-transform/components/queries-section/queries-section.component.html create mode 100644 src/app/dataset-view/additional-components/metadata-component/components/set-transform/components/queries-section/queries-section.component.sass create mode 100644 src/app/dataset-view/additional-components/metadata-component/components/set-transform/components/queries-section/queries-section.component.spec.ts create mode 100644 src/app/dataset-view/additional-components/metadata-component/components/set-transform/components/queries-section/queries-section.component.ts create mode 100644 src/app/dataset-view/additional-components/metadata-component/components/set-transform/components/search-section/search-section.component.html create mode 100644 src/app/dataset-view/additional-components/metadata-component/components/set-transform/components/search-section/search-section.component.sass create mode 100644 src/app/dataset-view/additional-components/metadata-component/components/set-transform/components/search-section/search-section.component.spec.ts create mode 100644 src/app/dataset-view/additional-components/metadata-component/components/set-transform/components/search-section/search-section.component.ts create mode 100644 src/app/dataset-view/additional-components/metadata-component/components/set-transform/edit-set-transform..service.spec.ts create mode 100644 src/app/dataset-view/additional-components/metadata-component/components/set-transform/edit-set-transform..service.ts create mode 100644 src/app/dataset-view/additional-components/metadata-component/components/set-transform/mock.data.ts create mode 100644 src/app/dataset-view/additional-components/metadata-component/components/set-transform/set-transform.component.html create mode 100644 src/app/dataset-view/additional-components/metadata-component/components/set-transform/set-transform.component.sass create mode 100644 src/app/dataset-view/additional-components/metadata-component/components/set-transform/set-transform.component.spec.ts create mode 100644 src/app/dataset-view/additional-components/metadata-component/components/set-transform/set-transform.component.ts create mode 100644 src/app/dataset-view/additional-components/metadata-component/components/set-transform/set-transform.types.ts create mode 100644 src/app/services/engine.service.spec.ts create mode 100644 src/app/services/engine.service.ts create mode 100644 src/assets/images/datafusion-logo.png diff --git a/CHANGELOG.md b/CHANGELOG.md index 15675a418..f5ad3bf04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Merge step - The ability to edit SetPollingSource event - Editing SetWaterMark event +- Editing SetTransform event ## [0.6.0] - 2023-02-27 diff --git a/resources/schema.graphql b/resources/schema.graphql index f10d42011..fe6d304d0 100644 --- a/resources/schema.graphql +++ b/resources/schema.graphql @@ -34,9 +34,10 @@ type Accounts { type AddData { inputCheckpoint: Multihash - outputData: DataSlice! + outputData: DataSlice outputCheckpoint: Checkpoint outputWatermark: DateTime + sourceState: SourceState } type AttachmentEmbedded { @@ -135,6 +136,10 @@ type DataQueries { Executes a specified query and returns its result """ query(query: String!, queryDialect: QueryDialect!, dataFormat: DataBatchFormat, schemaFormat: DataSchemaFormat, limit: Int): DataQueryResult! + """ + Lists engines known to the system and recommended for use + """ + knownEngines: [EngineDesc!]! } union DataQueryResult = DataQueryResultSuccess | DataQueryResultError @@ -179,7 +184,8 @@ type Dataset { id: DatasetID! """ Symbolic name of the dataset. - Name can change over the dataset's lifetime. For unique identifier use `id()`. + Name can change over the dataset's lifetime. For unique identifier use + `id()`. """ name: DatasetName! """ @@ -230,12 +236,14 @@ type DatasetData { """ numRecordsTotal: Int! """ - An estimated size of data on disk not accounting for replication or caching + An estimated size of data on disk not accounting for replication or + caching """ estimatedSize: Int! """ Returns the specified number of the latest records in the dataset - This is equivalent to the SQL query: `SELECT * FROM dataset ORDER BY event_time DESC LIMIT N` + This is equivalent to the SQL query: `SELECT * FROM dataset ORDER BY + event_time DESC LIMIT N` """ tail(limit: Int, dataFormat: DataBatchFormat, schemaFormat: DataSchemaFormat): DataQueryResult! } @@ -285,7 +293,8 @@ type DatasetMetadata { """ currentInfo: SetInfo! """ - Current readme file as discovered from attachments associated with the dataset + Current readme file as discovered from attachments associated with the + dataset """ currentReadme: String """ @@ -334,6 +343,27 @@ The input/output is a string in RFC3339 format. """ scalar DateTime +""" +Describes +""" +type EngineDesc { + """ + A short name of the engine, e.g. "Spark", "Flink". + Intended for use in UI for quick engine identification and selection. + """ + name: String! + """ + Language and dialect this engine is using for queries + Indended for configuring code highlighting and completions. + """ + dialect: QueryDialect! + """ + OCI image repository and a tag of the latest engine image, e.g. + "ghcr.io/kamu-data/engine-datafusion:0.1.2" + """ + latestImage: String! +} + type EnvVar { name: String! value: String @@ -453,7 +483,8 @@ type MetadataChain { """ blockByHash(hash: Multihash!): MetadataBlockExtended """ - Returns a metadata block corresponding to the specified hash and encoded in desired format + Returns a metadata block corresponding to the specified hash and encoded + in desired format """ blockByHashEncoded(hash: Multihash!, format: MetadataManifestFormat!): String """ @@ -484,6 +515,7 @@ scalar Multihash type Mutation { auth: Auth! + tasks: TasksMutations! } type OffsetInterval { @@ -516,7 +548,8 @@ type PageBasedInfo { """ currentPage: Int! """ - Approximate number of total pages assuming number of nodes per page stays the same + Approximate number of total pages assuming number of nodes per page + stays the same """ totalPages: Int } @@ -538,15 +571,29 @@ type Query { """ apiVersion: String! """ - Dataset-related functionality group + Dataset-related functionality group. + + Datasets are historical streams of events recorded under a cetrain + schema. """ datasets: Datasets! """ - Account-related functionality group + Account-related functionality group. + + Accounts can be individual users or organizations registered in the + system. This groups deals with their identities and permissions. """ accounts: Accounts! """ - Search-related functionality group + Task-related functionality group. + + Tasks are units of scheduling that can perform many functions like + ingesting new data, running dataset transformations, answering ad-hoc + queries etc. + """ + tasks: Tasks! + """ + Search-related functionality group. """ search: Search! """ @@ -556,7 +603,9 @@ type Query { } enum QueryDialect { - DATA_FUSION + SQL_SPARK + SQL_FLINK + SQL_DATA_FUSION } union ReadStep = ReadStepCsv | ReadStepJsonLines | ReadStepGeoJson | ReadStepEsriShapefile | ReadStepParquet @@ -694,12 +743,141 @@ enum SourceOrdering { BY_NAME } +type SourceState { + kind: String! + source: String! + value: String! +} + type SqlQueryStep { alias: String query: String! } +type Task { + """ + Unique and stable identitfier of this task + """ + taskId: TaskID! + """ + Life-cycle status of a task + """ + status: TaskStatus! + """ + Whether the task was ordered to be cancelled + """ + cancellationRequested: Boolean! + """ + Describes a certain final outcome of the task once it reaches the + "finished" status + """ + outcome: TaskOutcome + """ + Time when task was originally created and placed in a queue + """ + createdAt: DateTime! + """ + Time when task transitioned into a running state + """ + ranAt: DateTime + """ + Time when cancellation of task was requested + """ + cancellationRequestedAt: DateTime + """ + Time when task has reached a final outcome + """ + finishedAt: DateTime +} + +type TaskConnection { + """ + A shorthand for `edges { node { ... } }` + """ + nodes: [Task!]! + """ + Approximate number of total nodes + """ + totalCount: Int! + """ + Page information + """ + pageInfo: PageBasedInfo! + edges: [TaskEdge!]! +} + +type TaskEdge { + node: Task! +} + +scalar TaskID + +""" +Describes a certain final outcome of the task +""" +enum TaskOutcome { + """ + Task succeeded + """ + SUCCESS + """ + Task failed to complete + """ + FAILED + """ + Task was cancelled by a user + """ + CANCELLED +} + +""" +Life-cycle status of a task +""" +enum TaskStatus { + """ + Task is waiting for capacity to be allocated to it + """ + QUEUED + """ + Task is being executed + """ + RUNNING + """ + Task has reached a certain final outcome (see [TaskOutcome]) + """ + FINISHED +} + +type Tasks { + """ + Returns current state of a given task + """ + getTask(taskId: TaskID!): Task + """ + Returns states of tasks associated with a given dataset ordered by + creation time from newest to oldest + """ + listTasksByDataset(datasetId: DatasetID!, page: Int, perPage: Int): TaskConnection! +} + +type TasksMutations { + """ + Requests cancellation of the specified task + """ + cancelTask(taskId: TaskID!): Task! + """ + Schedules a task to update the specified dataset by performing polling + ingest or a derivative transformation + """ + createUpdateDatasetTask(datasetId: DatasetID!): Task! + """ + Schedules a task to update the specified dataset by performing polling + ingest or a derivative transformation + """ + createProbeTask(datasetId: DatasetID, busyTimeMs: Int, endWithOutcome: TaskOutcome): Task! +} + type TemporalTable { name: String! primaryKey: [String!]! diff --git a/src/app/api/dataset.api.ts b/src/app/api/dataset.api.ts index f951cdfaf..8e67e7704 100644 --- a/src/app/api/dataset.api.ts +++ b/src/app/api/dataset.api.ts @@ -5,6 +5,8 @@ import { CreateDatasetFromSnapshotQuery, CreateEmptyDatasetQuery, DatasetKind, + GetDatasetSchemaGQL, + GetDatasetSchemaQuery, } from "src/app/api/kamu.graphql.interface"; import AppValues from "src/app/common/app.values"; import { ApolloQueryResult } from "@apollo/client/core"; @@ -41,6 +43,7 @@ export class DatasetApi { private createEmptyDatasetGQL: CreateEmptyDatasetGQL, private createDatasetFromSnapshotGQL: CreateDatasetFromSnapshotGQL, private commitEventToDataset: CommitEventToDatasetGQL, + private datasetSchemaGQL: GetDatasetSchemaGQL, ) {} public getDatasetMainData(params: { @@ -97,6 +100,21 @@ export class DatasetApi { ); } + public getDatasetSchema( + datasetId: string, + ): Observable { + return this.datasetSchemaGQL + .watch({ + datasetId, + }) + .valueChanges.pipe( + first(), + map((result: ApolloQueryResult) => { + return result.data; + }), + ); + } + public fetchDatasetsByAccountName( accountName: string, page = 0, diff --git a/src/app/api/engine.api.spec.ts b/src/app/api/engine.api.spec.ts new file mode 100644 index 000000000..304a9a827 --- /dev/null +++ b/src/app/api/engine.api.spec.ts @@ -0,0 +1,20 @@ +import { TestBed } from "@angular/core/testing"; +import { ApolloTestingModule } from "apollo-angular/testing"; +import { EngineApi } from "./engine.api"; +import { Apollo, ApolloModule } from "apollo-angular"; + +describe("EngineApi", () => { + let service: EngineApi; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [EngineApi, Apollo], + imports: [ApolloModule, ApolloTestingModule], + }); + service = TestBed.inject(EngineApi); + }); + + it("should be created", () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/app/api/engine.api.ts b/src/app/api/engine.api.ts new file mode 100644 index 000000000..6efc13454 --- /dev/null +++ b/src/app/api/engine.api.ts @@ -0,0 +1,21 @@ +import { Injectable } from "@angular/core"; +import { EnginesGQL, EnginesQuery } from "./kamu.graphql.interface"; +import { ApolloQueryResult } from "@apollo/client"; +import { Observable } from "rxjs"; +import { first, map } from "rxjs/operators"; + +@Injectable({ + providedIn: "root", +}) +export class EngineApi { + constructor(private enginesGQL: EnginesGQL) {} + + public getEngines(): Observable { + return this.enginesGQL.watch().valueChanges.pipe( + first(), + map((result: ApolloQueryResult) => { + return result.data; + }), + ); + } +} diff --git a/src/app/api/gql/dataset-data-sql-run.graphql b/src/app/api/gql/dataset-data-sql-run.graphql index 793381b29..5a6fae2e8 100644 --- a/src/app/api/gql/dataset-data-sql-run.graphql +++ b/src/app/api/gql/dataset-data-sql-run.graphql @@ -2,7 +2,7 @@ query getDatasetDataSQLRun($query: String!, $limit: Int!) { data { query( query: $query - queryDialect: DATA_FUSION + queryDialect: SQL_DATA_FUSION schemaFormat: PARQUET_JSON dataFormat: JSON limit: $limit diff --git a/src/app/api/gql/dataset-schema.graphql b/src/app/api/gql/dataset-schema.graphql new file mode 100644 index 000000000..1e75494f8 --- /dev/null +++ b/src/app/api/gql/dataset-schema.graphql @@ -0,0 +1,13 @@ +query getDatasetSchema($datasetId: DatasetID!) { + datasets { + byId(datasetId: $datasetId) { + ...DatasetBasics + metadata { + currentSchema(format: PARQUET_JSON) { + format + content + } + } + } + } +} diff --git a/src/app/api/gql/engines.graphql b/src/app/api/gql/engines.graphql new file mode 100644 index 000000000..447c9ad36 --- /dev/null +++ b/src/app/api/gql/engines.graphql @@ -0,0 +1,9 @@ +query engines { + data { + knownEngines { + name + dialect + latestImage + } + } +} diff --git a/src/app/api/gql/fragments/fragment-dataset-overview.graphql b/src/app/api/gql/fragments/fragment-dataset-overview.graphql index 74f7980c5..c70b0b217 100644 --- a/src/app/api/gql/fragments/fragment-dataset-overview.graphql +++ b/src/app/api/gql/fragments/fragment-dataset-overview.graphql @@ -7,5 +7,8 @@ fragment DatasetOverview on Dataset { currentSource { __typename } + currentTransform { + __typename + } } } diff --git a/src/app/api/kamu.graphql.interface.ts b/src/app/api/kamu.graphql.interface.ts index d61c89f48..c8293fd5a 100644 --- a/src/app/api/kamu.graphql.interface.ts +++ b/src/app/api/kamu.graphql.interface.ts @@ -31,6 +31,7 @@ export type Scalars = { */ DateTime: any; Multihash: any; + TaskID: any; }; export type AccessToken = { @@ -74,8 +75,9 @@ export type AddData = { __typename?: "AddData"; inputCheckpoint?: Maybe; outputCheckpoint?: Maybe; - outputData: DataSlice; + outputData?: Maybe; outputWatermark?: Maybe; + sourceState?: Maybe; }; export type AttachmentEmbedded = { @@ -195,6 +197,8 @@ export enum DataBatchFormat { export type DataQueries = { __typename?: "DataQueries"; + /** Lists engines known to the system and recommended for use */ + knownEngines: Array; /** Executes a specified query and returns its result */ query: DataQueryResult; }; @@ -262,7 +266,8 @@ export type Dataset = { metadata: DatasetMetadata; /** * Symbolic name of the dataset. - * Name can change over the dataset's lifetime. For unique identifier use `id()`. + * Name can change over the dataset's lifetime. For unique identifier use + * `id()`. */ name: Scalars["DatasetName"]; /** Returns the user or organization that owns this dataset */ @@ -282,13 +287,17 @@ export type DatasetConnection = { export type DatasetData = { __typename?: "DatasetData"; - /** An estimated size of data on disk not accounting for replication or caching */ + /** + * An estimated size of data on disk not accounting for replication or + * caching + */ estimatedSize: Scalars["Int"]; /** Total number of records in this dataset */ numRecordsTotal: Scalars["Int"]; /** * Returns the specified number of the latest records in the dataset - * This is equivalent to the SQL query: `SELECT * FROM dataset ORDER BY event_time DESC LIMIT N` + * This is equivalent to the SQL query: `SELECT * FROM dataset ORDER BY + * event_time DESC LIMIT N` */ tail: DataQueryResult; }; @@ -319,7 +328,10 @@ export type DatasetMetadata = { currentInfo: SetInfo; /** Current license associated with the dataset */ currentLicense?: Maybe; - /** Current readme file as discovered from attachments associated with the dataset */ + /** + * Current readme file as discovered from attachments associated with the + * dataset + */ currentReadme?: Maybe; /** Latest data schema */ currentSchema?: Maybe; @@ -388,6 +400,26 @@ export type DatasetsCreateFromSnapshotArgs = { snapshotFormat: MetadataManifestFormat; }; +/** Describes */ +export type EngineDesc = { + __typename?: "EngineDesc"; + /** + * Language and dialect this engine is using for queries + * Indended for configuring code highlighting and completions. + */ + dialect: QueryDialect; + /** + * OCI image repository and a tag of the latest engine image, e.g. + * "ghcr.io/kamu-data/engine-datafusion:0.1.2" + */ + latestImage: Scalars["String"]; + /** + * A short name of the engine, e.g. "Spark", "Flink". + * Intended for use in UI for quick engine identification and selection. + */ + name: Scalars["String"]; +}; + export type EnvVar = { __typename?: "EnvVar"; name: Scalars["String"]; @@ -512,7 +544,10 @@ export type MetadataChain = { __typename?: "MetadataChain"; /** Returns a metadata block corresponding to the specified hash */ blockByHash?: Maybe; - /** Returns a metadata block corresponding to the specified hash and encoded in desired format */ + /** + * Returns a metadata block corresponding to the specified hash and encoded + * in desired format + */ blockByHashEncoded?: Maybe; /** Iterates all metadata blocks in the reverse chronological order */ blocks: MetadataBlockConnection; @@ -572,6 +607,7 @@ export type MetadataManifestUnsupportedVersion = CommitResult & export type Mutation = { __typename?: "Mutation"; auth: Auth; + tasks: TasksMutations; }; export type OffsetInterval = { @@ -596,7 +632,10 @@ export type PageBasedInfo = { hasNextPage: Scalars["Boolean"]; /** When paginating backwards, are there more items? */ hasPreviousPage: Scalars["Boolean"]; - /** Approximate number of total pages assuming number of nodes per page stays the same */ + /** + * Approximate number of total pages assuming number of nodes per page + * stays the same + */ totalPages?: Maybe; }; @@ -615,20 +654,40 @@ export type PrepStepPipe = { export type Query = { __typename?: "Query"; - /** Account-related functionality group */ + /** + * Account-related functionality group. + * + * Accounts can be individual users or organizations registered in the + * system. This groups deals with their identities and permissions. + */ accounts: Accounts; /** Returns the version of the GQL API */ apiVersion: Scalars["String"]; /** Querying and data manipulations */ data: DataQueries; - /** Dataset-related functionality group */ + /** + * Dataset-related functionality group. + * + * Datasets are historical streams of events recorded under a cetrain + * schema. + */ datasets: Datasets; - /** Search-related functionality group */ + /** Search-related functionality group. */ search: Search; + /** + * Task-related functionality group. + * + * Tasks are units of scheduling that can perform many functions like + * ingesting new data, running dataset transformations, answering ad-hoc + * queries etc. + */ + tasks: Tasks; }; export enum QueryDialect { - DataFusion = "DATA_FUSION", + SqlDataFusion = "SQL_DATA_FUSION", + SqlFlink = "SQL_FLINK", + SqlSpark = "SQL_SPARK", } export type ReadStep = @@ -787,12 +846,129 @@ export enum SourceOrdering { ByName = "BY_NAME", } +export type SourceState = { + __typename?: "SourceState"; + kind: Scalars["String"]; + source: Scalars["String"]; + value: Scalars["String"]; +}; + export type SqlQueryStep = { __typename?: "SqlQueryStep"; alias?: Maybe; query: Scalars["String"]; }; +export type Task = { + __typename?: "Task"; + /** Whether the task was ordered to be cancelled */ + cancellationRequested: Scalars["Boolean"]; + /** Time when cancellation of task was requested */ + cancellationRequestedAt?: Maybe; + /** Time when task was originally created and placed in a queue */ + createdAt: Scalars["DateTime"]; + /** Time when task has reached a final outcome */ + finishedAt?: Maybe; + /** + * Describes a certain final outcome of the task once it reaches the + * "finished" status + */ + outcome?: Maybe; + /** Time when task transitioned into a running state */ + ranAt?: Maybe; + /** Life-cycle status of a task */ + status: TaskStatus; + /** Unique and stable identitfier of this task */ + taskId: Scalars["TaskID"]; +}; + +export type TaskConnection = { + __typename?: "TaskConnection"; + edges: Array; + /** A shorthand for `edges { node { ... } }` */ + nodes: Array; + /** Page information */ + pageInfo: PageBasedInfo; + /** Approximate number of total nodes */ + totalCount: Scalars["Int"]; +}; + +export type TaskEdge = { + __typename?: "TaskEdge"; + node: Task; +}; + +/** Describes a certain final outcome of the task */ +export enum TaskOutcome { + /** Task was cancelled by a user */ + Cancelled = "CANCELLED", + /** Task failed to complete */ + Failed = "FAILED", + /** Task succeeded */ + Success = "SUCCESS", +} + +/** Life-cycle status of a task */ +export enum TaskStatus { + /** Task has reached a certain final outcome (see [TaskOutcome]) */ + Finished = "FINISHED", + /** Task is waiting for capacity to be allocated to it */ + Queued = "QUEUED", + /** Task is being executed */ + Running = "RUNNING", +} + +export type Tasks = { + __typename?: "Tasks"; + /** Returns current state of a given task */ + getTask?: Maybe; + /** + * Returns states of tasks associated with a given dataset ordered by + * creation time from newest to oldest + */ + listTasksByDataset: TaskConnection; +}; + +export type TasksGetTaskArgs = { + taskId: Scalars["TaskID"]; +}; + +export type TasksListTasksByDatasetArgs = { + datasetId: Scalars["DatasetID"]; + page?: InputMaybe; + perPage?: InputMaybe; +}; + +export type TasksMutations = { + __typename?: "TasksMutations"; + /** Requests cancellation of the specified task */ + cancelTask: Task; + /** + * Schedules a task to update the specified dataset by performing polling + * ingest or a derivative transformation + */ + createProbeTask: Task; + /** + * Schedules a task to update the specified dataset by performing polling + * ingest or a derivative transformation + */ + createUpdateDatasetTask: Task; +}; + +export type TasksMutationsCancelTaskArgs = { + taskId: Scalars["TaskID"]; +}; + +export type TasksMutationsCreateProbeTaskArgs = { + busyTimeMs?: InputMaybe; + datasetId?: InputMaybe; + endWithOutcome?: InputMaybe; +}; + +export type TasksMutationsCreateUpdateDatasetTaskArgs = { + datasetId: Scalars["DatasetID"]; +}; + export type TemporalTable = { __typename?: "TemporalTable"; name: Scalars["String"]; @@ -1007,6 +1183,30 @@ export type GetDatasetMainDataQuery = { }; }; +export type GetDatasetSchemaQueryVariables = Exact<{ + datasetId: Scalars["DatasetID"]; +}>; + +export type GetDatasetSchemaQuery = { + __typename?: "Query"; + datasets: { + __typename?: "Datasets"; + byId?: + | ({ + __typename?: "Dataset"; + metadata: { + __typename?: "DatasetMetadata"; + currentSchema?: { + __typename?: "DataSchema"; + format: DataSchemaFormat; + content: string; + } | null; + }; + } & DatasetBasicsFragment) + | null; + }; +}; + export type DatasetsByAccountNameQueryVariables = Exact<{ accountName: Scalars["AccountName"]; perPage?: InputMaybe; @@ -1030,17 +1230,32 @@ export type DatasetsByAccountNameQuery = { }; }; +export type EnginesQueryVariables = Exact<{ [key: string]: never }>; + +export type EnginesQuery = { + __typename?: "Query"; + data: { + __typename?: "DataQueries"; + knownEngines: Array<{ + __typename?: "EngineDesc"; + name: string; + dialect: QueryDialect; + latestImage: string; + }>; + }; +}; + export type AddDataEventFragment = { __typename?: "AddData"; inputCheckpoint?: any | null; addDataWatermark?: any | null; - outputData: { + outputData?: { __typename?: "DataSlice"; logicalHash: any; physicalHash: any; size: number; interval: { __typename?: "OffsetInterval"; start: number; end: number }; - }; + } | null; outputCheckpoint?: { __typename?: "Checkpoint"; physicalHash: any; @@ -1468,6 +1683,7 @@ export type DatasetOverviewFragment = { metadata: { __typename?: "DatasetMetadata"; currentSource?: { __typename: "SetPollingSource" } | null; + currentTransform?: { __typename: "SetTransform" } | null; }; } & DatasetDescriptionFragment & DatasetDetailsFragment & @@ -2187,6 +2403,9 @@ export const DatasetOverviewFragmentDoc = gql` currentSource { __typename } + currentTransform { + __typename + } } } ${DatasetDescriptionFragmentDoc} @@ -2359,7 +2578,7 @@ export const GetDatasetDataSqlRunDocument = gql` data { query( query: $query - queryDialect: DATA_FUSION + queryDialect: SQL_DATA_FUSION schemaFormat: PARQUET_JSON dataFormat: JSON limit: $limit @@ -2478,6 +2697,36 @@ export class GetDatasetMainDataGQL extends Apollo.Query< super(apollo); } } +export const GetDatasetSchemaDocument = gql` + query getDatasetSchema($datasetId: DatasetID!) { + datasets { + byId(datasetId: $datasetId) { + ...DatasetBasics + metadata { + currentSchema(format: PARQUET_JSON) { + format + content + } + } + } + } + } + ${DatasetBasicsFragmentDoc} +`; + +@Injectable({ + providedIn: "root", +}) +export class GetDatasetSchemaGQL extends Apollo.Query< + GetDatasetSchemaQuery, + GetDatasetSchemaQueryVariables +> { + document = GetDatasetSchemaDocument; + + constructor(apollo: Apollo.Apollo) { + super(apollo); + } +} export const DatasetsByAccountNameDocument = gql` query datasetsByAccountName( $accountName: AccountName! @@ -2518,6 +2767,31 @@ export class DatasetsByAccountNameGQL extends Apollo.Query< super(apollo); } } +export const EnginesDocument = gql` + query engines { + data { + knownEngines { + name + dialect + latestImage + } + } + } +`; + +@Injectable({ + providedIn: "root", +}) +export class EnginesGQL extends Apollo.Query< + EnginesQuery, + EnginesQueryVariables +> { + document = EnginesDocument; + + constructor(apollo: Apollo.Apollo) { + super(apollo); + } +} export const GithubLoginDocument = gql` mutation GithubLogin($code: String!) { auth { diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index b9cd97957..372daa6ab 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -13,6 +13,7 @@ import { AccountComponent } from "./auth/account/account.component"; import { GithubCallbackComponent } from "./auth/github-callback/github.callback"; import { environment } from "../environments/environment"; import ProjectLinks from "./project-links"; +import { SetTransformComponent } from "./dataset-view/additional-components/metadata-component/components/set-transform/set-transform.component"; const githubUrl = `https://github.com/login/oauth/authorize?scope=user:email&client_id=${environment.github_client_id}`; @@ -78,6 +79,12 @@ export const routes: Routes = [ `/${ProjectLinks.URL_PARAM_ADD_POLLING_SOURCE}`, component: AddPollingSourceComponent, }, + { + path: + `:${ProjectLinks.URL_PARAM_ACCOUNT_NAME}/:${ProjectLinks.URL_PARAM_DATASET_NAME}` + + `/${ProjectLinks.URL_PARAM_SET_TRANSFORM}`, + component: SetTransformComponent, + }, { path: "**", component: PageNotFoundComponent, diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 9a21d8354..e6ad8736c 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -66,7 +66,6 @@ import { DatasetsTabComponent } from "./auth/account/additional-components/datas import { ClipboardModule } from "@angular/cdk/clipboard"; import { HighlightModule, HIGHLIGHT_OPTIONS } from "ngx-highlightjs"; import { ToastrModule } from "ngx-toastr"; -import { AddPollingSourceComponent } from "./dataset-view/additional-components/metadata-component/components/add-polling-source/add-polling-source.component"; const Services = [ { @@ -137,7 +136,6 @@ const MatModules = [ NotificationIndicatorComponent, SettingsComponent, DatasetsTabComponent, - AddPollingSourceComponent, ], imports: [ AppRoutingModule, diff --git a/src/app/common/app.helpers.ts b/src/app/common/app.helpers.ts index bdaf4aaa7..bc9f48640 100644 --- a/src/app/common/app.helpers.ts +++ b/src/app/common/app.helpers.ts @@ -1,5 +1,7 @@ import moment from "moment"; import { MaybeNull } from "./app.types"; +import { GetDatasetSchemaQuery } from "../api/kamu.graphql.interface"; +import { DatasetSchema } from "../interface/dataset.interface"; export function requireValue(input: MaybeNull) { if (input === null) throw Error("value is required!"); @@ -57,3 +59,13 @@ export function momentConvertDatetoLocalWithFormat(dateParams: { return moment(ISOStringDate).format(dateParams.format); } + +export function parseCurrentSchema( + data: GetDatasetSchemaQuery, +): MaybeNull { + return data.datasets.byId?.metadata.currentSchema + ? (JSON.parse( + data.datasets.byId.metadata.currentSchema.content, + ) as DatasetSchema) + : null; +} diff --git a/src/app/common/base-yaml-event.service.ts b/src/app/common/base-yaml-event.service.ts new file mode 100644 index 000000000..eae72c7da --- /dev/null +++ b/src/app/common/base-yaml-event.service.ts @@ -0,0 +1,89 @@ +import { inject } from "@angular/core"; +import { DatasetService } from "../dataset-view/dataset.service"; +import { Observable, EMPTY, iif, of, zip, Subject } from "rxjs"; +import { expand, last, map, switchMap } from "rxjs/operators"; +import { + DatasetKind, + MetadataBlockFragment, +} from "../api/kamu.graphql.interface"; +import { BlockService } from "../dataset-block/metadata-block/block.service"; +import { SupportedEvents } from "../dataset-block/metadata-block/components/event-details/supported.events"; +import { DatasetHistoryUpdate } from "../dataset-view/dataset.subscriptions.interface"; +import { DatasetInfo } from "../interface/navigation.interface"; + +export abstract class BaseYamlEventService { + private appDatasetService = inject(DatasetService); + private blockService = inject(BlockService); + private currentPage = 0; + private readonly historyPageSize = 100; + public history: DatasetHistoryUpdate; + private kindChanges$: Subject = new Subject(); + public changeKindChanges(data: DatasetKind): void { + this.kindChanges$.next(data); + } + public get onKindChanges(): Observable { + return this.kindChanges$.asObservable(); + } + + public getEventAsYaml( + info: DatasetInfo, + typename: SupportedEvents, + ): Observable { + return this.appDatasetService + .getDatasetHistory(info, this.historyPageSize, this.currentPage) + .pipe( + expand((h: DatasetHistoryUpdate) => { + const filteredHistory = this.filterHistoryByType( + h.history, + typename, + ); + return filteredHistory.length === 0 && + h.pageInfo.hasNextPage + ? this.appDatasetService.getDatasetHistory( + info, + this.historyPageSize, + h.pageInfo.currentPage + 1, + ) + : EMPTY; + }), + map((h: DatasetHistoryUpdate) => { + if (h.kind) { + this.changeKindChanges(h.kind); + } + this.history = h; + const filteredHistory = this.filterHistoryByType( + h.history, + typename, + ); + return filteredHistory; + }), + switchMap((filteredHistory: MetadataBlockFragment[]) => + iif( + () => !filteredHistory.length, + of(null), + zip( + this.blockService.onMetadataBlockAsYamlChanges, + this.blockService.requestMetadataBlock( + info, + filteredHistory[0]?.blockHash as string, + ), + ), + ), + ), + map((result: [string, unknown] | null) => { + if (result) return result[0]; + else return null; + }), + last(), + ); + } + + private filterHistoryByType( + history: MetadataBlockFragment[], + typename: string, + ): MetadataBlockFragment[] { + return history.filter( + (item: MetadataBlockFragment) => item.event.__typename === typename, + ); + } +} diff --git a/src/app/common/data.helpers.ts b/src/app/common/data.helpers.ts index 4c2bbbcf8..adc94b65b 100644 --- a/src/app/common/data.helpers.ts +++ b/src/app/common/data.helpers.ts @@ -39,6 +39,12 @@ export class DataHelpers { name: "Apache Spark", url_logo: "assets/images/apache-spark.png", }; + + case "datafusion": + return { + name: "DataFusion", + url_logo: "assets/images/datafusion-logo.png", + }; default: console.log("Engine is not defined"); return { @@ -131,9 +137,11 @@ export class DataHelpers { switch (event.__typename) { case "AddData": return `Added ${ - event.outputData.interval.end - - event.outputData.interval.start + - 1 + event.outputData + ? event.outputData.interval.end - + event.outputData.interval.start + + 1 + : 0 } new records`; case "ExecuteQuery": return `Transformation produced ${ diff --git a/src/app/dataset-block/metadata-block/components/event-details/components/common/sql-query-viewer/sql-query-viewer.component.ts b/src/app/dataset-block/metadata-block/components/event-details/components/common/sql-query-viewer/sql-query-viewer.component.ts index ef7249d66..466f7cb24 100644 --- a/src/app/dataset-block/metadata-block/components/event-details/components/common/sql-query-viewer/sql-query-viewer.component.ts +++ b/src/app/dataset-block/metadata-block/components/event-details/components/common/sql-query-viewer/sql-query-viewer.component.ts @@ -1,6 +1,5 @@ import { SqlQueryStep } from "../../../../../../../api/kamu.graphql.interface"; import { ChangeDetectionStrategy, Component, Input } from "@angular/core"; -import { sqlEditorOptionsForEvents } from "../../../config-editor.events"; import { BasePropertyComponent } from "../base-property/base-property.component"; @Component({ @@ -11,5 +10,4 @@ import { BasePropertyComponent } from "../base-property/base-property.component" }) export class SqlQueryViewerComponent extends BasePropertyComponent { @Input() public data: SqlQueryStep[]; - public sqlEditorOptions = sqlEditorOptionsForEvents; } diff --git a/src/app/dataset-block/metadata-block/components/event-details/config-editor.events.ts b/src/app/dataset-block/metadata-block/components/event-details/config-editor.events.ts index d39251af1..f487844cb 100644 --- a/src/app/dataset-block/metadata-block/components/event-details/config-editor.events.ts +++ b/src/app/dataset-block/metadata-block/components/event-details/config-editor.events.ts @@ -1,14 +1,11 @@ import * as monaco from "monaco-editor"; -export const sqlEditorOptionsForEvents: monaco.editor.IStandaloneEditorConstructionOptions = +export const sqlEditorOptions: monaco.editor.IStandaloneEditorConstructionOptions = { theme: "vs", language: "sql", - contextmenu: false, - wordWrap: "on", - readOnly: true, renderLineHighlight: "none", - lineNumbers: "off", minimap: { enabled: false, }, + scrollBeyondLastLine: false, }; diff --git a/src/app/dataset-create/dataset-create.component.html b/src/app/dataset-create/dataset-create.component.html index a34c2f03f..256658bd7 100644 --- a/src/app/dataset-create/dataset-create.component.html +++ b/src/app/dataset-create/dataset-create.component.html @@ -125,7 +125,7 @@

Create a new dataset

> Query: - +
+ +

Error:

{{ sqlErrorMarker }}

diff --git a/src/app/dataset-view/additional-components/data-component/data-component.ts b/src/app/dataset-view/additional-components/data-component/data-component.ts index 6d69f771f..c087b7a93 100644 --- a/src/app/dataset-view/additional-components/data-component/data-component.ts +++ b/src/app/dataset-view/additional-components/data-component/data-component.ts @@ -21,6 +21,7 @@ import { BaseComponent } from "src/app/common/base.component"; import { DatasetBasicsFragment } from "src/app/api/kamu.graphql.interface"; import * as monaco from "monaco-editor"; import { MaybeNull } from "src/app/common/app.types"; +import { sqlEditorOptions } from "src/app/dataset-block/metadata-block/components/event-details/config-editor.events"; @Component({ selector: "app-data", @@ -30,14 +31,7 @@ import { MaybeNull } from "src/app/common/app.types"; export class DataComponent extends BaseComponent implements OnInit { @Input() public datasetBasics?: DatasetBasicsFragment; @Output() public runSQLRequestEmit = new EventEmitter(); - public sqlEditorOptions = { - theme: "vs", - language: "sql", - renderLineHighlight: "none", - minimap: { - enabled: false, - }, - }; + public sqlEditorOptions = sqlEditorOptions; public savedQueries = DataTabValues.savedQueries; public sqlRequestCode = `select\n *\nfrom `; diff --git a/src/app/dataset-view/additional-components/metadata-component/components/add-polling-source/add-polling-source.component.html b/src/app/dataset-view/additional-components/metadata-component/components/add-polling-source/add-polling-source.component.html index c93dc6750..0a3eb8bda 100644 --- a/src/app/dataset-view/additional-components/metadata-component/components/add-polling-source/add-polling-source.component.html +++ b/src/app/dataset-view/additional-components/metadata-component/components/add-polling-source/add-polling-source.component.html @@ -1,4 +1,4 @@ -
+
+ +
There is no metadata. >

There is no metadata. Need add - SetTransform event + SetTransform event

diff --git a/src/app/dataset-view/additional-components/overview-component/overview-component.html b/src/app/dataset-view/additional-components/overview-component/overview-component.html index 4f27ee605..63a7a3934 100644 --- a/src/app/dataset-view/additional-components/overview-component/overview-component.html +++ b/src/app/dataset-view/additional-components/overview-component/overview-component.html @@ -89,6 +89,20 @@

>

+
+

+ You can + + Add transformation +

+
= data.datasets - .byOwnerAndName.metadata.currentSchema - ? (JSON.parse( - data.datasets.byOwnerAndName.metadata - .currentSchema.content, - ) as DatasetSchema) - : null; - + const schema: MaybeNull = + parseCurrentSchema(data); this.datasetUpdate(data.datasets.byOwnerAndName); this.overviewTabDataUpdate( data.datasets.byOwnerAndName, @@ -157,6 +153,7 @@ export class DatasetService { history: data.datasets.byOwnerAndName.metadata.chain .blocks.nodes as MetadataBlockFragment[], pageInfo, + kind: data.datasets.byOwnerAndName.kind, }; return historyUpdate; } else { @@ -207,6 +204,12 @@ export class DatasetService { return this.datasetApi.getDatasetInfoById(datasetId); } + public requestDatasetSchema( + datasetId: string, + ): Observable { + return this.datasetApi.getDatasetSchema(datasetId); + } + private datasetUpdate(data: DatasetBasicsFragment): void { const dataset: DatasetBasicsFragment = data; this.datasetChanges(dataset); diff --git a/src/app/dataset-view/dataset.subscriptions.interface.ts b/src/app/dataset-view/dataset.subscriptions.interface.ts index 2547d009a..9df8821c3 100644 --- a/src/app/dataset-view/dataset.subscriptions.interface.ts +++ b/src/app/dataset-view/dataset.subscriptions.interface.ts @@ -1,4 +1,5 @@ import { + DatasetKind, DatasetPageInfoFragment, SetVocab, } from "./../api/kamu.graphql.interface"; @@ -38,6 +39,7 @@ export interface MetadataSchemaUpdate { export interface DatasetHistoryUpdate { history: MetadataBlockFragment[]; pageInfo: DatasetPageInfoFragment; + kind?: DatasetKind; } export interface LineageUpdate { diff --git a/src/app/project-links.ts b/src/app/project-links.ts index c741c3a83..1d169abd4 100644 --- a/src/app/project-links.ts +++ b/src/app/project-links.ts @@ -29,6 +29,7 @@ export default class ProjectLinks { public static readonly URL_PARAM_BLOCK_HASH: string = "blockHash"; public static readonly URL_PARAM_ADD_POLLING_SOURCE: string = "add-polling-source"; + public static readonly URL_PARAM_SET_TRANSFORM: string = "set-transform"; public static readonly URL_QUERY_PARAM_TAB: string = "tab"; public static readonly URL_QUERY_PARAM_PAGE: string = "page"; diff --git a/src/app/services/engine.service.spec.ts b/src/app/services/engine.service.spec.ts new file mode 100644 index 000000000..29f466d95 --- /dev/null +++ b/src/app/services/engine.service.spec.ts @@ -0,0 +1,20 @@ +import { TestBed } from "@angular/core/testing"; +import { EngineService } from "./engine.service"; +import { Apollo, ApolloModule } from "apollo-angular"; +import { ApolloTestingModule } from "apollo-angular/testing"; + +describe("EngineService", () => { + let service: EngineService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [Apollo], + imports: [ApolloModule, ApolloTestingModule], + }); + service = TestBed.inject(EngineService); + }); + + it("should be created", () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/app/services/engine.service.ts b/src/app/services/engine.service.ts new file mode 100644 index 000000000..c2e61ab87 --- /dev/null +++ b/src/app/services/engine.service.ts @@ -0,0 +1,15 @@ +import { Injectable } from "@angular/core"; +import { EngineApi } from "../api/engine.api"; +import { Observable } from "rxjs"; +import { EnginesQuery } from "../api/kamu.graphql.interface"; + +@Injectable({ + providedIn: "root", +}) +export class EngineService { + constructor(private engineApi: EngineApi) {} + + public engines(): Observable { + return this.engineApi.getEngines(); + } +} diff --git a/src/app/services/navigation.service.ts b/src/app/services/navigation.service.ts index a329cdc05..aa24214fc 100644 --- a/src/app/services/navigation.service.ts +++ b/src/app/services/navigation.service.ts @@ -69,6 +69,16 @@ export class NavigationService { ); } + public navigateToSetTransform(params: DatasetInfo): void { + promiseWithCatch( + this.router.navigate([ + params.accountName, + params.datasetName, + ProjectLinks.URL_PARAM_SET_TRANSFORM, + ]), + ); + } + public navigateToDatasetView(params: DatasetNavigationParams): void { promiseWithCatch( this.router.navigate([params.accountName, params.datasetName], { diff --git a/src/app/services/templates-yaml-events.service.ts b/src/app/services/templates-yaml-events.service.ts index 275ae974a..653967a27 100644 --- a/src/app/services/templates-yaml-events.service.ts +++ b/src/app/services/templates-yaml-events.service.ts @@ -1,4 +1,8 @@ -import { SetLicense, SetPollingSource } from "./../api/kamu.graphql.interface"; +import { + SetLicense, + SetPollingSource, + SetTransform, +} from "./../api/kamu.graphql.interface"; import { Injectable } from "@angular/core"; import { MaybeNull } from "../common/app.types"; import { stringify } from "yaml"; @@ -56,6 +60,16 @@ export class TemplatesYamlEventsService { return stringify(this.initialTemplate); } + public buildYamlSetTransformEvent( + params: Omit, + ): string { + this.initialTemplate.content = { + kind: "setTransform", + ...params, + }; + return stringify(this.initialTemplate); + } + public buildYamlSetWatermarkEvent(dateTime: string): string { let result = this.initialSetWatermarkTemplate; result += ` outputWatermark: ${dateTime}`; diff --git a/src/assets/images/datafusion-logo.png b/src/assets/images/datafusion-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..1cc7a9ce62b23181603c328dc8b6d9db0f509f35 GIT binary patch literal 17551 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 S(eR5#5UPrr3e|Gvq5lWXXU@q0 literal 0 HcmV?d00001 diff --git a/src/styles.sass b/src/styles.sass index 77f258c40..ab05ddb16 100644 --- a/src/styles.sass +++ b/src/styles.sass @@ -1034,3 +1034,32 @@ pre .cdk-global-scrollblock overflow: hidden !important + +.mat-tree + .mat-tree-node + .mat-icon-button + background-color: transparent !important + +.w-425 + width: 370px!important + +.button-custom + display: flex + padding: 5px 10px + align-items: center + justify-content: center + border: 1px solid black + border-radius: 5px + &:hover + cursor: pointer + filter: alpha(opacity=90) + -moz-opacity: 0.9 + opacity: 0.9 + +.no-button + border: none + background: transparent + &:hover + cursor: pointer + &:enabled + color: blue