diff --git a/.github/workflows/CI-CD-k8s-gear-js-stage.yml b/.github/workflows/CI-CD-k8s-gear-js-stage.yml index 61e4db767e..af2bf722e0 100644 --- a/.github/workflows/CI-CD-k8s-gear-js-stage.yml +++ b/.github/workflows/CI-CD-k8s-gear-js-stage.yml @@ -1,4 +1,4 @@ -name: "Gear Idea: deploy to k8s stage" +name: 'Gear Idea: deploy to k8s stage' on: push: @@ -23,9 +23,7 @@ env: AWS_REGION: ${{ secrets.AWS_REGION }} KUBE_CONFIG_DATA: ${{ secrets.KUBE_CONFIG_DATA }} - jobs: - build-frontend-image-staging: runs-on: ubuntu-latest environment: staging @@ -58,6 +56,7 @@ jobs: VITE_NODES_API_URL= ${{ secrets.REACT_APP_DEFAULT_NODES_URL }} VITE_MAINNET_VOUCHERS_API_URL=${{ secrets.VITE_MAINNET_VOUCHERS_API_URL }} VITE_TESTNET_VOUCHERS_API_URL=${{ secrets.VITE_TESTNET_VOUCHERS_API_URL }} + VITE_DNS_API_URL=${{ secrets.VITE_DNS_API_URL }} build-indexer-image-staging: runs-on: ubuntu-latest @@ -161,12 +160,13 @@ jobs: tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-meta-storage:qa deploy-to-k8s-staging: - needs: [ + needs: + [ build-frontend-image-staging, build-indexer-image-staging, build-api-gateway-image-staging, build-test-balance-image-staging, - build-and-push-meta-storage-image + build-and-push-meta-storage-image, ] runs-on: ubuntu-latest diff --git a/.github/workflows/CI-CD-k8s-release-deploy.yml b/.github/workflows/CI-CD-k8s-release-deploy.yml index fd512f0fb9..b0cd6dfd5c 100644 --- a/.github/workflows/CI-CD-k8s-release-deploy.yml +++ b/.github/workflows/CI-CD-k8s-release-deploy.yml @@ -67,6 +67,7 @@ jobs: VITE_GTM_ID=${{ secrets.VITE_GTM_ID}} VITE_MAINNET_VOUCHERS_API_URL=${{ secrets.VITE_MAINNET_VOUCHERS_API_URL }} VITE_TESTNET_VOUCHERS_API_URL=${{ secrets.VITE_TESTNET_VOUCHERS_API_URL }} + VITE_DNS_API_URL=${{ secrets.VITE_DNS_API_URL }} build-indexer-image-prod: runs-on: ubuntu-latest diff --git a/idea/frontend/.env.example b/idea/frontend/.env.example index f7010c3d74..fd694a6516 100644 --- a/idea/frontend/.env.example +++ b/idea/frontend/.env.example @@ -6,3 +6,4 @@ VITE_NODE_ADDRESS= VITE_HCAPTCHA_SITE_KEY= VITE_GTM_ID= VITE_DEFAULT_TRANSFER_BALANCE_VALUE= +VITE_DNS_API_URL= diff --git a/idea/frontend/Dockerfile b/idea/frontend/Dockerfile index 923e7fed6d..99885468f9 100644 --- a/idea/frontend/Dockerfile +++ b/idea/frontend/Dockerfile @@ -21,7 +21,8 @@ ARG VITE_NODE_ADDRESS \ VITE_HCAPTCHA_SITE_KEY \ VITE_GTM_ID \ VITE_MAINNET_VOUCHERS_API_URL \ - VITE_TESTNET_VOUCHERS_API_URL + VITE_TESTNET_VOUCHERS_API_URL \ + VITE_DNS_API_URL ENV VITE_NODE_ADDRESS=${VITE_NODE_ADDRESS} \ VITE_VOUCHERS_API_URL=${VITE_VOUCHERS_API_URL} \ VITE_API_URL=${VITE_API_URL} \ @@ -30,7 +31,8 @@ ENV VITE_NODE_ADDRESS=${VITE_NODE_ADDRESS} \ VITE_HCAPTCHA_SITE_KEY=${VITE_HCAPTCHA_SITE_KEY} \ VITE_GTM_ID=${VITE_GTM_ID} \ VITE_MAINNET_VOUCHERS_API_URL=${VITE_MAINNET_VOUCHERS_API_URL} \ - VITE_TESTNET_VOUCHERS_API_URL=${VITE_TESTNET_VOUCHERS_API_URL} + VITE_TESTNET_VOUCHERS_API_URL=${VITE_TESTNET_VOUCHERS_API_URL} \ + VITE_DNS_API_URL=${VITE_DNS_API_URL} RUN yarn build:frontend diff --git a/idea/frontend/src/features/dns/assets/dns-card-placeholder.svg b/idea/frontend/src/features/dns/assets/dns-card-placeholder.svg new file mode 100644 index 0000000000..d9616f1cad --- /dev/null +++ b/idea/frontend/src/features/dns/assets/dns-card-placeholder.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/idea/frontend/src/features/dns/consts/index.ts b/idea/frontend/src/features/dns/consts/index.ts new file mode 100644 index 0000000000..82888bfc50 --- /dev/null +++ b/idea/frontend/src/features/dns/consts/index.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; + +import { Values } from '../types'; +import { Program } from './sails'; + +const DNS_API_URL = import.meta.env.VITE_DNS_API_URL as string; + +const DNS_PROGRAM_QUERY_KEY = ['dnsProgram']; + +const FIELD_NAME = { + DNS_NAME: 'name', + DNS_ADDRESS: 'address', +} as const; + +const DEFAULT_VALUES: Values = { + [FIELD_NAME.DNS_ADDRESS]: '', + [FIELD_NAME.DNS_NAME]: '', +}; + +const domainNameRegex = /^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; +const NAME_SCHEMA = z.string().trim().regex(domainNameRegex, 'Invalid domain name'); + +export { DNS_API_URL, DNS_PROGRAM_QUERY_KEY, FIELD_NAME, DEFAULT_VALUES, NAME_SCHEMA, Program }; diff --git a/idea/frontend/src/features/dns/consts/sails.ts b/idea/frontend/src/features/dns/consts/sails.ts new file mode 100644 index 0000000000..53f895107b --- /dev/null +++ b/idea/frontend/src/features/dns/consts/sails.ts @@ -0,0 +1,72 @@ +import { GearApi } from '@gear-js/api'; +import { TypeRegistry } from '@polkadot/types'; +import { TransactionBuilder } from 'sails-js'; + +export type ActorId = `0x${string}`; + +export interface ContractInfo { + admin: ActorId; + program_id: ActorId; + registration_time: string; +} + +export class Program { + public readonly registry: TypeRegistry; + public readonly dns: Dns; + + constructor(public api: GearApi, public programId?: `0x${string}`) { + const types = { + ActorId: '([u8; 32])', + ContractInfo: { admin: 'ActorId', program_id: 'ActorId', registration_time: 'String' }, + }; + + this.registry = new TypeRegistry(); + this.registry.setKnownTypes({ types }); + this.registry.register(types); + + this.dns = new Dns(this); + } +} + +export class Dns { + constructor(private _program: Program) {} + + public addNewProgram(name: string, program_id: ActorId): TransactionBuilder { + if (!this._program.programId) throw new Error('Program ID is not set'); + return new TransactionBuilder( + this._program.api, + this._program.registry, + 'send_message', + ['Dns', 'AddNewProgram', name, program_id], + '(String, String, String, ActorId)', + 'Null', + this._program.programId, + ); + } + + public changeProgramId(name: string, new_program_id: ActorId): TransactionBuilder { + if (!this._program.programId) throw new Error('Program ID is not set'); + return new TransactionBuilder( + this._program.api, + this._program.registry, + 'send_message', + ['Dns', 'ChangeProgramId', name, new_program_id], + '(String, String, String, ActorId)', + 'Null', + this._program.programId, + ); + } + + public deleteProgram(name: string): TransactionBuilder { + if (!this._program.programId) throw new Error('Program ID is not set'); + return new TransactionBuilder( + this._program.api, + this._program.registry, + 'send_message', + ['Dns', 'DeleteProgram', name], + '(String, String, String)', + 'Null', + this._program.programId, + ); + } +} diff --git a/idea/frontend/src/features/dns/hooks/index.ts b/idea/frontend/src/features/dns/hooks/index.ts new file mode 100644 index 0000000000..3ece77313b --- /dev/null +++ b/idea/frontend/src/features/dns/hooks/index.ts @@ -0,0 +1,8 @@ +import { useDnsSort } from './use-dns-sort'; +import { useDns } from './use-dns'; +import { useDnsFilters } from './use-dns-filters'; +import { useDnsActions } from './use-dns-actions'; +import { useInitDnsProgram } from './use-init-dns-program'; +import { useDnsSchema } from './use-dns-schema'; + +export { useDns, useDnsFilters, useDnsSort, useDnsActions, useInitDnsProgram, useDnsSchema }; diff --git a/idea/frontend/src/features/dns/hooks/use-dns-actions.ts b/idea/frontend/src/features/dns/hooks/use-dns-actions.ts new file mode 100644 index 0000000000..e1cbd7dc6f --- /dev/null +++ b/idea/frontend/src/features/dns/hooks/use-dns-actions.ts @@ -0,0 +1,85 @@ +import { HexString } from '@gear-js/api'; +import { useAccount } from '@gear-js/react-hooks'; +import { web3FromSource } from '@polkadot/extension-dapp'; +import { TransactionBuilder } from 'sails-js'; + +import { Method } from '@/features/explorer'; +import { useLoading, useModal, useSignAndSend } from '@/hooks'; +import { TransactionName } from '@/shared/config'; + +import { DNS_PROGRAM_QUERY_KEY, Program } from '../consts'; +import { useQueryClient } from '@tanstack/react-query'; + +type ResolveRejectOptions = { + resolve?: () => void; + reject?: () => void; +}; + +type SendDnsMessage = { + getTransactionBuilder: () => TransactionBuilder; + options?: ResolveRejectOptions; +}; + +const useDnsActions = () => { + const [isLoading, enableLoading, disableLoading] = useLoading(); + const { showModal } = useModal(); + const { account } = useAccount(); + const signAndSend = useSignAndSend(); + const queryClient = useQueryClient(); + const state = queryClient.getQueryState(DNS_PROGRAM_QUERY_KEY); + const dnsProgram = state?.data; + + const sendMessage = async ({ getTransactionBuilder, options }: SendDnsMessage) => { + if (!account || !dnsProgram) { + return; + } + const { resolve: onSuccess, reject: onError } = options || {}; + const { signer } = await web3FromSource(account.meta.source); + const transaction = getTransactionBuilder(); + transaction.withAccount(account.address, { signer }); + await transaction.calculateGas(); + + const extrinsic = transaction.extrinsic; + const { partialFee } = await extrinsic.paymentInfo(account.address, { signer }); + + const handleConfirm = () => { + enableLoading(); + signAndSend(extrinsic, Method.MessageQueued, { + onSuccess, + onError, + onFinally: () => disableLoading(), + }); + }; + + showModal('transaction', { + fee: partialFee.toHuman(), + name: TransactionName.SendMessage, + addressFrom: account.address, + addressTo: dnsProgram.programId, + onAbort: onError, + onConfirm: handleConfirm, + }); + }; + + const addNewProgram = (name: string, program_id: HexString, options?: ResolveRejectOptions) => { + if (!dnsProgram) throw new Error('dnsProgram is not initialized'); + const getTransactionBuilder = () => dnsProgram.dns.addNewProgram(name, program_id); + return sendMessage({ getTransactionBuilder, options }); + }; + + const changeProgramId = (name: string, program_id: HexString, options?: ResolveRejectOptions) => { + if (!dnsProgram) throw new Error('dnsProgram is not initialized'); + const getTransactionBuilder = () => dnsProgram.dns.changeProgramId(name, program_id); + return sendMessage({ getTransactionBuilder, options }); + }; + + const deleteProgram = (name: string, options?: ResolveRejectOptions) => { + if (!dnsProgram) throw new Error('dnsProgram is not initialized'); + const getTransactionBuilder = () => dnsProgram.dns.deleteProgram(name); + return sendMessage({ getTransactionBuilder, options }); + }; + + return { isLoading, addNewProgram, changeProgramId, deleteProgram }; +}; + +export { useDnsActions }; diff --git a/idea/frontend/src/features/dns/hooks/use-dns-filters.ts b/idea/frontend/src/features/dns/hooks/use-dns-filters.ts new file mode 100644 index 0000000000..3a5d655604 --- /dev/null +++ b/idea/frontend/src/features/dns/hooks/use-dns-filters.ts @@ -0,0 +1,26 @@ +import { useAccount } from '@gear-js/react-hooks'; +import { useMemo, useState } from 'react'; + +const DEFAULT_FILTER_VALUES = { + owner: 'all', +}; + +function useDnsFilters() { + const { account } = useAccount(); + const [values, setValues] = useState(DEFAULT_FILTER_VALUES); + + const getOwnerParams = () => { + if (!account) return {}; + + const { decodedAddress } = account; + const { owner } = values; + + return owner === 'all' ? {} : { createdBy: decodedAddress }; + }; + + const params = useMemo(() => getOwnerParams(), [values, account]); + + return [values, params, setValues] as const; +} + +export { useDnsFilters }; diff --git a/idea/frontend/src/features/dns/hooks/use-dns-schema.ts b/idea/frontend/src/features/dns/hooks/use-dns-schema.ts new file mode 100644 index 0000000000..9df0c00a82 --- /dev/null +++ b/idea/frontend/src/features/dns/hooks/use-dns-schema.ts @@ -0,0 +1,16 @@ +import { useProgramIdSchema } from '@/hooks'; +import { z } from 'zod'; +import { FIELD_NAME, NAME_SCHEMA } from '../consts'; + +const useDnsSchema = () => { + const programIdSchema = useProgramIdSchema([]); + + const dnsSchema = z.object({ + [FIELD_NAME.DNS_ADDRESS]: programIdSchema, + [FIELD_NAME.DNS_NAME]: NAME_SCHEMA, + }); + + return dnsSchema; +}; + +export { useDnsSchema }; diff --git a/idea/frontend/src/features/dns/hooks/use-dns-sort.ts b/idea/frontend/src/features/dns/hooks/use-dns-sort.ts new file mode 100644 index 0000000000..1837e00590 --- /dev/null +++ b/idea/frontend/src/features/dns/hooks/use-dns-sort.ts @@ -0,0 +1,22 @@ +import { useState } from 'react'; +import { SortDirection } from '../types'; + +const DEFAULT_SORT_VALUES = { + orderByField: 'updatedAt', + orderByDirection: 'DESC' as SortDirection, +}; + +function useDnsSort() { + const [values, setValues] = useState(DEFAULT_SORT_VALUES); + + const toggleDirection = () => { + setValues(({ orderByField, orderByDirection }) => ({ + orderByField, + orderByDirection: orderByDirection === 'ASC' ? 'DESC' : 'ASC', + })); + }; + + return [values, toggleDirection, setValues] as const; +} + +export { useDnsSort }; diff --git a/idea/frontend/src/features/dns/hooks/use-dns.ts b/idea/frontend/src/features/dns/hooks/use-dns.ts new file mode 100644 index 0000000000..8ec8279d37 --- /dev/null +++ b/idea/frontend/src/features/dns/hooks/use-dns.ts @@ -0,0 +1,23 @@ +import { useInfiniteQuery } from '@tanstack/react-query'; + +import { DEFAULT_LIMIT } from '@/shared/config'; + +import { getDns, getNextPageParam } from '../utils'; +import { DnsFilterParams, DnsSortParams } from '../types'; + +function useDns(search: string, filterParams: DnsFilterParams, sortParams: DnsSortParams) { + const { data, isLoading, hasNextPage, fetchNextPage, refetch } = useInfiniteQuery({ + queryKey: ['dns', search, filterParams, sortParams], + queryFn: ({ pageParam }) => + getDns({ limit: DEFAULT_LIMIT, offset: pageParam, search, ...filterParams, ...sortParams }), + initialPageParam: 0, + getNextPageParam, + }); + + const dns = data?.pages.flatMap((page) => page.data) || []; + const dnsCount = data?.pages[0].count || 0; + + return [dns, dnsCount, isLoading, hasNextPage, fetchNextPage, refetch] as const; +} + +export { useDns }; diff --git a/idea/frontend/src/features/dns/hooks/use-init-dns-program.ts b/idea/frontend/src/features/dns/hooks/use-init-dns-program.ts new file mode 100644 index 0000000000..d692c7d31a --- /dev/null +++ b/idea/frontend/src/features/dns/hooks/use-init-dns-program.ts @@ -0,0 +1,30 @@ +import { useApi } from '@gear-js/react-hooks'; + +import { DNS_API_URL, DNS_PROGRAM_QUERY_KEY, Program } from '../consts'; +import { useQuery } from '@tanstack/react-query'; + +const useInitDnsProgram = () => { + const { isApiReady, api } = useApi(); + + const getDnsProgram = () => + fetch(`${DNS_API_URL}/dns/contract`).then((response) => { + return response.json().then(({ contract }) => { + const programId = contract; + if (isApiReady) { + return new Program(api, programId); + } + }); + }); + + const { data, isPending } = useQuery({ + queryKey: DNS_PROGRAM_QUERY_KEY, + queryFn: getDnsProgram, + enabled: isApiReady, + }); + + const isLoading = isPending; + + return { data, isLoading }; +}; + +export { useInitDnsProgram }; diff --git a/idea/frontend/src/features/dns/index.ts b/idea/frontend/src/features/dns/index.ts new file mode 100644 index 0000000000..ab00d95ef4 --- /dev/null +++ b/idea/frontend/src/features/dns/index.ts @@ -0,0 +1,8 @@ +import { DnsCard, CreateDns } from './ui'; +import DnsCardPlaceholder from './assets/dns-card-placeholder.svg?react'; +import { useDns, useDnsFilters, useDnsSort, useInitDnsProgram } from './hooks'; +import { Dns } from './types'; + +export { DnsCard, DnsCardPlaceholder, useDns, useDnsFilters, CreateDns, useDnsSort, useInitDnsProgram }; + +export type { Dns }; diff --git a/idea/frontend/src/features/dns/types.ts b/idea/frontend/src/features/dns/types.ts new file mode 100644 index 0000000000..8019b1b161 --- /dev/null +++ b/idea/frontend/src/features/dns/types.ts @@ -0,0 +1,36 @@ +import { HexString } from '@gear-js/api'; +import { FIELD_NAME } from './consts'; + +type Values = { + [FIELD_NAME.DNS_ADDRESS]: string; + [FIELD_NAME.DNS_NAME]: string; +}; + +type Dns = { + id: string; // same as 'name' at contract + name: string; + address: HexString; // same as 'program_id' at contract + createdBy: HexString; + createdAt: string; + updatedAt: string; +}; + +type SortDirection = 'ASC' | 'DESC'; +type DnsParams = { + limit: number; + offset: number; + createdBy?: string; + search?: string; + orderByField: string; + orderByDirection: SortDirection; +}; + +type DnsFilterParams = Pick; +type DnsSortParams = Pick; + +type DnsResponse = { + data: Dns[]; + count: number; +}; + +export type { Values, Dns, DnsParams, DnsResponse, DnsFilterParams, DnsSortParams, SortDirection }; diff --git a/idea/frontend/src/features/dns/ui/create-dns/create-dns.tsx b/idea/frontend/src/features/dns/ui/create-dns/create-dns.tsx new file mode 100644 index 0000000000..e5498cc4c5 --- /dev/null +++ b/idea/frontend/src/features/dns/ui/create-dns/create-dns.tsx @@ -0,0 +1,25 @@ +import { Button } from '@gear-js/ui'; + +import { useModalState } from '@/hooks'; +import { withAccount } from '@/shared/ui'; +import EditSVG from '@/shared/assets/images/actions/edit.svg?react'; + +import { DnsModal } from '../dns-modal'; + +type Props = { + onSuccess: () => void; +}; + +const CreateDns = withAccount(({ onSuccess }: Props) => { + const [isModalOpen, openModal, closeModal] = useModalState(); + + return ( + <> +