From fb67d1c5a557ad0bd005032e572d85355f63ab15 Mon Sep 17 00:00:00 2001 From: Murtaza Saadat Date: Thu, 2 Nov 2023 16:17:55 -0400 Subject: [PATCH] Add the ability to switch out your project and env from the electron app --- package-lock.json | 24 +- package.json | 1 + src/components/dashboard/DashboardPage.js | 58 ++- src/electron.js | 9 + src/preload.js | 7 + src/services/crudEnv/constants.js | 74 ++++ src/services/crudEnv/projectAndEnv.js | 420 ++++++++++++++++++++++ src/services/crudEnv/routes.js | 32 ++ src/styles/scss/dashboard-page.scss | 18 + 9 files changed, 637 insertions(+), 6 deletions(-) create mode 100644 src/services/crudEnv/constants.js create mode 100644 src/services/crudEnv/projectAndEnv.js create mode 100644 src/services/crudEnv/routes.js diff --git a/package-lock.json b/package-lock.json index 907cb9b..098c685 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "jquery": "^3.6.0", "lodash-es": "^4.17.21", "monaco-editor": "^0.33.0", + "netrc": "^0.1.4", "path-browserify": "^1.0.1", "pg-format": "^1.0.4", "pluralize": "^8.0.0", @@ -13591,6 +13592,7 @@ "node_modules/fs-xattr": { "version": "0.3.1", "dev": true, + "hasInstallScript": true, "license": "MIT", "optional": true, "os": [ @@ -19284,6 +19286,7 @@ "node_modules/macos-alias": { "version": "0.2.11", "dev": true, + "hasInstallScript": true, "license": "MIT", "optional": true, "os": [ @@ -21055,6 +21058,11 @@ "version": "2.6.2", "license": "MIT" }, + "node_modules/netrc": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/netrc/-/netrc-0.1.4.tgz", + "integrity": "sha512-ye8AIYWQcP9MvoM1i0Z2jV0qed31Z8EWXYnyGNkiUAd+Fo8J+7uy90xTV8g/oAbhtjkY7iZbNTizQaXdKUuwpQ==" + }, "node_modules/next-tick": { "version": "1.1.0", "dev": true, @@ -29412,16 +29420,17 @@ } }, "node_modules/typescript": { - "version": "5.2.2", + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "dev": true, - "license": "Apache-2.0", "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }, "engines": { - "node": ">=14.17" + "node": ">=4.2.0" } }, "node_modules/typewriter-effect": { @@ -45853,6 +45862,11 @@ "neo-async": { "version": "2.6.2" }, + "netrc": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/netrc/-/netrc-0.1.4.tgz", + "integrity": "sha512-ye8AIYWQcP9MvoM1i0Z2jV0qed31Z8EWXYnyGNkiUAd+Fo8J+7uy90xTV8g/oAbhtjkY7iZbNTizQaXdKUuwpQ==" + }, "next-tick": { "version": "1.1.0", "dev": true @@ -51557,7 +51571,9 @@ } }, "typescript": { - "version": "5.2.2", + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "dev": true, "peer": true }, diff --git a/package.json b/package.json index a251e94..3a71897 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "jquery": "^3.6.0", "lodash-es": "^4.17.21", "monaco-editor": "^0.33.0", + "netrc": "^0.1.4", "path-browserify": "^1.0.1", "pg-format": "^1.0.4", "pluralize": "^8.0.0", diff --git a/src/components/dashboard/DashboardPage.js b/src/components/dashboard/DashboardPage.js index 27aa98a..ca70e66 100644 --- a/src/components/dashboard/DashboardPage.js +++ b/src/components/dashboard/DashboardPage.js @@ -27,6 +27,7 @@ import { } from '../../svgs/icons' import useCurrentProject from '../../hooks/useCurrentProject' import logger from '../../utils/logger' +import SelectInput from '../shared/inputs/SelectInput' const className = 'dashboard' const pcn = getPCN(className) @@ -54,6 +55,30 @@ function DashboardPage(props) { const [seedCursors, setSeedCursors] = useState([]) const [currentSchemaName, _] = useState(DEFAULT_SCHEMA_NAME) const [tables, setTables] = useState(null) + const [projects, setProjects] = useState([]) + const [databaseEnvironments, setDatabaseEnvironments] = useState([]) + const [currentProjectEnv, setCurrentProjectEnv] = useState({ + project: null, + env: null, + }) + + const getProjects = async () => { + const currEnv = await window.electronAPI.showEnv() + const currProject = await window.electronAPI.showProject() + const projects = await window.electronAPI.getUserProjects() + const newDatabaseEnvironments = await window.electronAPI.getCurrentProjectEnvs(currProject) + + setDatabaseEnvironments(newDatabaseEnvironments) + setProjects(projects) + setCurrentProjectEnv({ + project: currProject, + env: currEnv, + }) + } + + useEffect(() => { + getProjects() + }, []) const tableNames = useMemo(() => tables?.map(t => t.name) || null, [tables]) const tablesBodyRef = useRef() @@ -269,7 +294,36 @@ function DashboardPage(props) {
- { renderHeaderProjectPath() } + ({ value: p, label: p }))} + classNamePrefix='spec' + isRequired={true} + updateFromAbove={true} + className={pcn('__col-input-field')} + placeholder={`${project?.org || 'org'}/${project?.name || 'project'}`} + onChange={async (val) => { + await window.electronAPI.useProject(val) + getProjects() + }} + value={currentProjectEnv.project ? currentProjectEnv.project : "Select a project..."} + /> + ({ value: p, label: p }))} + classNamePrefix='spec' + isRequired={true} + updateFromAbove={true} + className={pcn('__col-input-field')} + placeholder={`${currentProjectEnv.env || 'local'}`} + onChange={async (val) => { + window.electronAPI.useEnv(val) + setCurrentProjectEnv({ + ...currentProjectEnv, + env: val, + })}} + value={currentProjectEnv.env ? currentProjectEnv.env : "Select a database environment..."} + /> +
+
@@ -289,7 +343,7 @@ function DashboardPage(props) {
- ), [currentTable, renderHeaderProjectPath, renderContentBodyComp]) + ), [currentTable, renderHeaderProjectPath, renderContentBodyComp, currentProjectEnv, projects, databaseEnvironments]) return (
diff --git a/src/electron.js b/src/electron.js index ceaeed4..bd9bdf5 100644 --- a/src/electron.js +++ b/src/electron.js @@ -21,6 +21,8 @@ const { performQuery, } = require('@spec.dev/app-db') const url = require('url') +const { useProject, getUserProjects, getCurrentProjectEnvs, useEnv, showEnv, showProject } = require('./services/crudEnv/projectAndEnv') + const { stringify, parse } = JSON @@ -262,6 +264,13 @@ app.whenReady().then(() => { ipcMain.handle('query', query) ipcMain.handle('createSpecClient', createSpecClient) ipcMain.handle('killSpecClient', killSpecClient) + ipcMain.handle('useProject', useProject) + ipcMain.handle('getProjects', getProjects) + ipcMain.handle('getUserProjects', getUserProjects) + ipcMain.handle('getCurrentProjectEnvs', getCurrentProjectEnvs) + ipcMain.handle('useEnv', useEnv) + ipcMain.handle('showProject', showProject) + ipcMain.handle('showEnv', showEnv) createWindow() }) diff --git a/src/preload.js b/src/preload.js index 32c80af..2f89c19 100644 --- a/src/preload.js +++ b/src/preload.js @@ -10,6 +10,13 @@ contextBridge.exposeInMainWorld('electronAPI', { subscribeToPath: (...args) => ipcRenderer.invoke('subscribeToPath', ...args), createSpecClient: (...args) => ipcRenderer.invoke('createSpecClient', ...args), killSpecClient: () => ipcRenderer.invoke('killSpecClient'), + useProject: (...args) => ipcRenderer.invoke('useProject', ...args), + getProjects: (...args) => ipcRenderer.invoke('getProjects', ...args), + getUserProjects: (...args) => ipcRenderer.invoke('getUserProjects', ...args), + getCurrentProjectEnvs: (...args) => ipcRenderer.invoke('getCurrentProjectEnvs', ...args), + useEnv: (...args) => ipcRenderer.invoke('useEnv', ...args), + showProject: (...args) => ipcRenderer.invoke('showProject', ...args), + showEnv: (...args) => ipcRenderer.invoke('showEnv', ...args), on: (...args) => ipcRenderer.on(...args), off: (...args) => ipcRenderer.removeAllListeners(...args), send: (...args) => ipcRenderer.send(...args), diff --git a/src/services/crudEnv/constants.js b/src/services/crudEnv/constants.js new file mode 100644 index 0000000..d70b823 --- /dev/null +++ b/src/services/crudEnv/constants.js @@ -0,0 +1,74 @@ +const path = require('path') +const os = require('os') + +const ev = (name, fallback) => + process.env.hasOwnProperty(name) ? process.env[name] : fallback + +const constants = { + // Spec project config. + SPEC_CONFIG_DIR_NAME: '.spec', + CONNECTION_CONFIG_FILE_NAME: 'connect.toml', + PROJECT_CONFIG_FILE_NAME: 'project.toml', + MIGRATIONS_DIR_NAME: 'migrations', + HANDLERS_DIR_NAME: 'handlers', + HOOKS_DIR_NAME: 'hooks', + GRAPHQL_DIR_NAME: 'graphql', + + // Global CLI config. + SPEC_GLOBAL_DIR: path.join(os.homedir(), '.spec'), + SPEC_GLOBAL_STATE_FILE_NAME: 'state.toml', + SPEC_GLOBAL_CREDS_FILE_NAME: 'creds.toml', + SPEC_GLOBAL_PROJECTS_FILE_NAME: 'projects.toml', + + // Spec base/ecosystem. + SPEC_ORIGIN: ev('SPEC_ORIGIN', 'https://spec.dev'), + + // Spec API config. + SPEC_API_ORIGIN: ev('SPEC_API_ORIGIN', 'https://api.spec.dev'), + USER_AUTH_HEADER_NAME: 'Spec-User-Auth-Token', + + // DB defaults. + SPEC_DB_USER: 'spec', + DB_PORT: 5432, + + // Live Table testing. + LIVE_OBJECT_TESTING_DB_NAME: 'live-object-testing', + LIVE_OBJECT_TESTING_API_PORT: 8000, + + // Desktop app name. + SPEC_APP_NAME: 'Spec', + + // Default log size. + DEFAULT_LOG_TAIL_SIZE: 20, +} + +constants.SPEC_CONFIG_DIR = path.join(process.cwd(), constants.SPEC_CONFIG_DIR_NAME) +constants.CONNECTION_CONFIG_PATH = path.join( + constants.SPEC_CONFIG_DIR, + constants.CONNECTION_CONFIG_FILE_NAME +) +constants.PROJECT_CONFIG_PATH = path.join( + constants.SPEC_CONFIG_DIR, + constants.PROJECT_CONFIG_FILE_NAME +) +constants.SPEC_GLOBAL_STATE_PATH = path.join( + constants.SPEC_GLOBAL_DIR, + constants.SPEC_GLOBAL_STATE_FILE_NAME +) +constants.SPEC_GLOBAL_CREDS_PATH = path.join( + constants.SPEC_GLOBAL_DIR, + constants.SPEC_GLOBAL_CREDS_FILE_NAME +) +constants.SPEC_GLOBAL_PROJECTS_PATH = path.join( + constants.SPEC_GLOBAL_DIR, + constants.SPEC_GLOBAL_PROJECTS_FILE_NAME +) +constants.SPEC_GLOBAL_COMPOSE_DIR = path.join(constants.SPEC_GLOBAL_DIR, 'compose') +constants.SPEC_HANDLERS_DIR = path.join(constants.SPEC_CONFIG_DIR, constants.HANDLERS_DIR_NAME) +constants.SPEC_HOOKS_DIR = path.join(constants.SPEC_CONFIG_DIR, constants.HOOKS_DIR_NAME) + + +module.exports = { + constants, + ev +} \ No newline at end of file diff --git a/src/services/crudEnv/projectAndEnv.js b/src/services/crudEnv/projectAndEnv.js new file mode 100644 index 0000000..a70c3cb --- /dev/null +++ b/src/services/crudEnv/projectAndEnv.js @@ -0,0 +1,420 @@ +const fs = require('fs') +const toml = require('@ltd/j-toml') +const { constants } = require('./constants') +const netrc = require('netrc') +const fetch = require('node-fetch') +const { routes } = require('./routes') +const path = require('path') + +const createDir = (path) => fs.mkdirSync(path) + +const createFileWithContents = (path, contents) => + fs.writeFileSync(path, contents) + +const fileExists = (path) => fs.existsSync(path) + +function readTomlConfigFile(path) { + if (!fileExists(path)) { + return { data: {} } + } + try { + const data = toml.parse(fs.readFileSync(path, 'utf-8')) + return { data } + } catch (error) { + return { error } + } +} + +function saveTomlConfigFile(path, table) { + let error + try { + createFileWithContents( + path, + toml.stringify(table, { newlineAround: 'section', newline: '\n' }) + ) + } catch (err) { + error = err + } + return { error } +} + +function saveGlobalStateFile(table) { + return saveTomlConfigFile(constants.SPEC_GLOBAL_STATE_PATH, table) +} + +function readGlobalStateFile() { + return readTomlConfigFile(constants.SPEC_GLOBAL_STATE_PATH) +} + +function saveState(updates) { + // Ensure spec global config directory exists. + upsertSpecGlobalDir() + + // Get current global state. + const { data, error } = readGlobalStateFile() + if (error) return { error } + + // Apply and save updates. + return saveGlobalStateFile({ ...data, ...updates }) +} + +function upsertSpecGlobalDir() { + fileExists(constants.SPEC_GLOBAL_DIR) || createDir(constants.SPEC_GLOBAL_DIR) +} + +async function useEnv(_, env) { + /* + TODO: + ---- + 1) Get the current project id from state.toml + 2) Resolve the full project from projects.toml + 3) Use the projects "location" to read connect.toml + 4) Validate the env exists in connect.toml before setting. + */ + const { error: setEnvError } = saveState({ projectEnv: env }) + if (setEnvError) { + console.error(setEnvError) + return + } + +} + +function readGlobalCredsFile() { + return readTomlConfigFile(constants.SPEC_GLOBAL_CREDS_PATH) +} + +function saveGlobalCredsFile(table) { + saveTomlConfigFile(constants.SPEC_GLOBAL_CREDS_PATH, table) +} + +const DEFAULT_PROJECT_ENV = 'local' + +function saveProjectCreds( + nsp, + name, + id, + apiKey +) { + // Ensure spec global config directory exists. + upsertSpecGlobalDir() + + // Get current global creds. + const { data, error } = readGlobalCredsFile() + if (error) return { error } + + // Upsert project section within file. + const creds = data || {} + const projectPath = [nsp, name].join('/') + creds[projectPath] = creds[projectPath] || toml.Section({}) + creds[projectPath].id = id + creds[projectPath].apiKey = apiKey + + return saveGlobalCredsFile(creds) +} + +function getCurrentProjectId() { + const { data, error } = readGlobalStateFile() + if (error) return { error } + return { data: data?.projectId || null } +} + +function repoPathToComponents(path) { + const splitPath = path.split('/') + return splitPath.length === 2 ? splitPath : null +} + +function getNetrcEntryId() { + const url = new URL(buildUrl('')) + return url.hostname +} + +function getSessionToken() { + let token = null + let error = null + try { + const entries = netrc() + token = (entries[getNetrcEntryId()] || {}).password || null + } catch (err) { + error = err?.message || err + } + return { token, error } +} + +async function get( + url, + args, + headers = undefined, + returnRawResponse = undefined +) { + const params = new URLSearchParams() + for (let key in args) { + params.append(key, args[key]) + } + + let resp, err + try { + resp = await fetch(`${url}?${params.toString()}`, { headers }) + } catch (err) { + err = err + } + if (err) return { error: err } + + if (returnRawResponse) { + return { data: resp } + } + + const { data, error } = await parseJSONResp(resp) + if (error) return { error } + + return { + data, + headers: resp.headers, + } +} + +async function parseJSONResp(resp) { + let data = {} + try { + data = await resp.json() + } catch (err) { + return { error: `Error parsing JSON response: ${err}.` } + } + if (data.error) { + return { error: data.error } + } + if (resp.status !== 200) { + return { error: `Request failed with status ${resp.status}.` } + } + return { data } +} + +const formatAuthHeader = (sessionToken) => ({ + [constants.USER_AUTH_HEADER_NAME]: sessionToken, +}) + +function removeTrailingSlash(str) { + return str.replace(/\/+$/, '') +} + +const buildUrl = (route) => { + return [removeTrailingSlash(constants.SPEC_API_ORIGIN), route].join('/') +} + +async function getUserProjects() { + const { token, error: tokenError } = getSessionToken() + const { data, error: dataError } = await get( + buildUrl(routes.GET_PROJECTS), + null, + formatAuthHeader(token) + ) + + if (dataError || tokenError) { + return [] + } else { + const mappedData = data.map(project => { + return `${project?.namespace?.name}/${project?.slug}` + }) + return mappedData + } + +} + +async function getProject( + namespace, + project, + sessionToken +) { + // Perform link request. + const { data, error } = await get( + buildUrl(routes.GET_PROJECT), + { namespace, project }, + formatAuthHeader(sessionToken) + ) + if (error) return { error } + + // Return project info. + return { + id: data.id || '', + name: data.slug || '', + namespace: data.namespace?.name || '', + apiKey: data.apiKey, + metadata: data.metadata || {}, + } +} + +function showProject() { + // Get current project id. + const { data: projectId, error } = getCurrentProjectId() + if (error) { + return + } + if (!projectId) { + return + } + + // Get project info from global spec creds file. + const { data, error: infoError } = getProjectCreds(projectId) + if (infoError) { + return + } + if (!data?.path) { + return + } + + return data.path +} + +function getCurrentProjectEnv() { + const { data, error } = readGlobalStateFile() + if (error) return { error } + return { data: data?.projectEnv || null } +} + +function showEnv() { + const { data: env, error } = getCurrentProjectEnv() + if (error) { + return + } + + return env +} + +async function useProject(_, projectPath, logResult = true) { + // Split input into namespace/project. + const pathComps = repoPathToComponents(projectPath) + if (!pathComps) { + console.warn('Please specify the project in / format.') + return + } + const [nsp, projectName] = pathComps + + + // Get authed user's session token (if any). + const { token, error } = getSessionToken() + if (error) { + console.error(error) + return + } + if (!token) { + console.error("Auth required") + return + } + + // Resolve user's project by nsp/name. + const projectInfo = await getProject(nsp, projectName, token) + const { + id, + name, + namespace, + apiKey, + metadata, + } = projectInfo + if (projectInfo.error) { + console.error(`Failed to resolve project ${projectPath}: ${projectInfo.error}`) + return + } + if (!id || !name || !namespace || !apiKey) { + console.error( + `Failed to resolve project with ${projectPath}.\n + Couldn't resolve all necessary project attributes:\n + id=${id}\n + name=${name}\n + namespace=${namespace}\n + apiKey=${apiKey}` + ) + return + } + + // Save project id and api key to global creds file. + const result = saveProjectCreds(namespace, name, id, apiKey) + if (result && result.error) { + console.error(result.error) + return + } + + // Set current project id in global state. + const { error: setProjectIdError } = saveState({ + projectId: id, + projectEnv: DEFAULT_PROJECT_ENV, + }) + if (setProjectIdError) { + console.error(setProjectIdError) + return + } + + return { id, metadata } +} + +function toMap(obj) { + const newObj = {} + for (let key in obj) { + newObj[key] = obj[key] + } + return newObj +} + +function getProjectCreds(projectId){ + const { data, error } = readGlobalCredsFile() + if (error) return { error } + + const creds = toMap(data || {}) + for (const key in creds) { + const projectCreds = creds[key] + if (projectCreds.id === projectId) { + return { data: { ...projectCreds, path: key } } + } + } + return { data: null } +} + +function readGlobalProjectsFile() { + return readTomlConfigFile(constants.SPEC_GLOBAL_PROJECTS_PATH) +} + +function getDBConfig(projectDirPath, projectEnv) { + const connectFilePath = path.join( + projectDirPath, + constants.SPEC_CONFIG_DIR_NAME, + constants.CONNECTION_CONFIG_FILE_NAME + ) + + // Ensure connection config file exists. + if (!fileExists(connectFilePath)) { + return { error: null } + } + + // Return config for given environment. + try { + const data = toml.parse(fs.readFileSync(connectFilePath, 'utf-8')) || {} + return { data } + } catch (error) { + return { error } + } +} + +async function getCurrentProjectEnvs(_, projectPath) { + if (!projectPath) { + return; + } + const { data: stateData, error: stateError } = readGlobalStateFile() + const { data: globalData, error: globalError } = readGlobalProjectsFile() + if (globalError || stateError) { + return [] + } + const env = stateData?.projectEnv + const location = globalData[projectPath].location + const { data: dbData, error: dbError } = getDBConfig(location, env) + if (dbError) { + return [] + } + return Object.keys(dbData) || [] +} + +module.exports = { + useProject, + useEnv, + getUserProjects, + getCurrentProjectEnvs, + showProject, + showEnv +} \ No newline at end of file diff --git a/src/services/crudEnv/routes.js b/src/services/crudEnv/routes.js new file mode 100644 index 0000000..c734853 --- /dev/null +++ b/src/services/crudEnv/routes.js @@ -0,0 +1,32 @@ +const prefix = { + USER: 'user', + PROJECT: 'project', + DEPLOYMENT: 'deployment', + CONTRACT_INSTANCES: 'contract-instances', + CONTRACT_REGISTRATION_JOB: 'contract-registration-job', + CONTRACT: 'contract', + ABI: 'abi', + EVENT_VERSION: 'event-version', + LIVE_OBJECT_VERSION: 'live-object-version', +} + +const routes = { + LOGIN: [prefix.USER, 'login'].join('/'), + GET_PROJECT: [prefix.PROJECT, 'with-key'].join('/'), + GET_PROJECTS: [prefix.USER, 'projects'].join('/'), + CREATE_DEPLOYMENT: [prefix.DEPLOYMENT].join('/'), + PROJECT_LOGS: [prefix.PROJECT, 'logs'].join('/'), + GET_ABI: prefix.ABI, + REGISTER_CONTRACTS: [prefix.CONTRACT_INSTANCES, 'register'].join('/'), + GET_CONTRACT_REGISTRATION_JOB: prefix.CONTRACT_REGISTRATION_JOB, + CREATE_CONTRACT_GROUP: [prefix.CONTRACT, 'group'].join('/'), + GET_CONTRACT_GROUP: [prefix.CONTRACT, 'group'].join('/'), + GET_CONTRACT_GROUP_EVENTS: [prefix.CONTRACT, 'group', 'events'].join('/'), + RESOLVE_EVENT_VERSION_CURSORS: [prefix.EVENT_VERSION + 's', 'resolve', 'cursors'].join('/'), + GET_EVENT_VERSION_DATA_AFTER: [prefix.EVENT_VERSION + 's', 'data', 'after'].join('/'), + GET_LIVE_OBJECT_VERSION: prefix.LIVE_OBJECT_VERSION, +} + +module.exports = { + routes +} \ No newline at end of file diff --git a/src/styles/scss/dashboard-page.scss b/src/styles/scss/dashboard-page.scss index ba7e98f..f21bda2 100644 --- a/src/styles/scss/dashboard-page.scss +++ b/src/styles/scss/dashboard-page.scss @@ -219,6 +219,24 @@ .dashboard__content-header-left { display: inline-block; + padding-top: 12px; + display: flex; + flex-direction: row; + gap: 8px; +} + +.dashboard__content-header-select { + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + height: 100%; + padding-right: 4px; +} + +.dashboard__col-input-field { + width: 144px; + height: 35px; } .dashboard__content-header-right {