Skip to content

Instantly share code, notes, and snippets.

@Akryum
Last active November 25, 2019 18:29
Show Gist options
  • Save Akryum/05964e81d09fb5088b7769cff15f5e7c to your computer and use it in GitHub Desktop.
Save Akryum/05964e81d09fb5088b7769cff15f5e7c to your computer and use it in GitHub Desktop.
Example of migration to Vue Function-based Component API
<script>
import { isValidMultiName } from '@/util/folders'
import FOLDER_CURRENT from '@/graphql/folder/folderCurrent.gql'
import FOLDERS_FAVORITE from '@/graphql/folder/foldersFavorite.gql'
import FOLDER_OPEN from '@/graphql/folder/folderOpen.gql'
import FOLDER_OPEN_PARENT from '@/graphql/folder/folderOpenParent.gql'
import FOLDER_SET_FAVORITE from '@/graphql/folder/folderSetFavorite.gql'
import PROJECT_CWD_RESET from '@/graphql/project/projectCwdReset.gql'
import FOLDER_CREATE from '@/graphql/folder/folderCreate.gql'
const SHOW_HIDDEN = 'vue-ui.show-hidden-folders'
export default {
data () {
return {
loading: 0,
error: false,
editingPath: false,
editedPath: '',
folderCurrent: {},
foldersFavorite: [],
showHidden: localStorage.getItem(SHOW_HIDDEN) === 'true',
showNewFolder: false,
newFolderName: ''
}
},
apollo: {
folderCurrent: {
query: FOLDER_CURRENT,
fetchPolicy: 'network-only',
loadingKey: 'loading',
async result () {
await this.$nextTick()
this.$refs.folders.scrollTop = 0
}
},
foldersFavorite: FOLDERS_FAVORITE
},
computed: {
newFolderValid () {
return isValidMultiName(this.newFolderName)
}
},
watch: {
showHidden (value) {
if (value) {
localStorage.setItem(SHOW_HIDDEN, 'true')
} else {
localStorage.removeItem(SHOW_HIDDEN)
}
}
},
beforeRouteLeave (to, from, next) {
if (to.matched.some(m => m.meta.needProject)) {
this.resetProjectCwd()
}
next()
},
methods: {
async openFolder (path) {
this.editingPath = false
this.error = null
this.loading++
try {
await this.$apollo.mutate({
mutation: FOLDER_OPEN,
variables: {
path
},
update: (store, { data: { folderOpen } }) => {
store.writeQuery({ query: FOLDER_CURRENT, data: { folderCurrent: folderOpen } })
}
})
} catch (e) {
this.error = e
}
this.loading--
},
async openParentFolder (folder) {
this.editingPath = false
this.error = null
this.loading++
try {
await this.$apollo.mutate({
mutation: FOLDER_OPEN_PARENT,
update: (store, { data: { folderOpenParent } }) => {
store.writeQuery({ query: FOLDER_CURRENT, data: { folderCurrent: folderOpenParent } })
}
})
} catch (e) {
this.error = e
}
this.loading--
},
async toggleFavorite () {
await this.$apollo.mutate({
mutation: FOLDER_SET_FAVORITE,
variables: {
path: this.folderCurrent.path,
favorite: !this.folderCurrent.favorite
},
update: (store, { data: { folderSetFavorite } }) => {
store.writeQuery({ query: FOLDER_CURRENT, data: { folderCurrent: folderSetFavorite } })
let data = store.readQuery({ query: FOLDERS_FAVORITE })
// TODO this is a workaround
// See: https://github.com/apollographql/apollo-client/issues/4031#issuecomment-433668473
data = {
foldersFavorite: data.foldersFavorite.slice()
}
if (folderSetFavorite.favorite) {
data.foldersFavorite.push(folderSetFavorite)
} else {
const index = data.foldersFavorite.findIndex(
f => f.path === folderSetFavorite.path
)
index !== -1 && data.foldersFavorite.splice(index, 1)
}
store.writeQuery({ query: FOLDERS_FAVORITE, data })
}
})
},
cwdChangedUpdate (previousResult, { subscriptionData }) {
return {
cwd: subscriptionData.data.cwd
}
},
async openPathEdit () {
this.editedPath = this.folderCurrent.path
this.editingPath = true
await this.$nextTick()
this.$refs.pathInput.focus()
},
submitPathEdit () {
this.openFolder(this.editedPath)
},
refreshFolder () {
this.openFolder(this.folderCurrent.path)
},
resetProjectCwd () {
this.$apollo.mutate({
mutation: PROJECT_CWD_RESET
})
},
async createFolder () {
if (!this.newFolderValid) return
const result = await this.$apollo.mutate({
mutation: FOLDER_CREATE,
variables: {
name: this.newFolderName
}
})
this.openFolder(result.data.folderCreate.path)
this.newFolderName = ''
this.showNewFolder = false
},
slicePath (path) {
const parts = []
let startIndex = 0
let index
const findSeparator = () => {
index = path.indexOf('/', startIndex)
if (index === -1) index = path.indexOf('\\', startIndex)
return index !== -1
}
const addPart = index => {
const folder = path.substring(startIndex, index)
const slice = path.substring(0, index + 1)
parts.push({
name: folder,
path: slice
})
}
while (findSeparator()) {
addPart(index)
startIndex = index + 1
}
if (startIndex < path.length) addPart(path.length)
return parts
}
}
}
</script>
<script>
import { useQuery, mutate } from 'vue-apollo'
import { nextTick, state, value, watch } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
import { isValidMultiName } from '@/util/folders'
import FOLDER_CURRENT from '@/graphql/folder/folderCurrent.gql'
import FOLDERS_FAVORITE from '@/graphql/folder/favoriteFolders.gql'
import FOLDER_OPEN from '@/graphql/folder/folderOpen.gql'
import FOLDER_OPEN_PARENT from '@/graphql/folder/folderOpenParent.gql'
import FOLDER_SET_FAVORITE from '@/graphql/folder/folderSetFavorite.gql'
import PROJECT_CWD_RESET from '@/graphql/project/projectCwdReset.gql'
import FOLDER_CREATE from '@/graphql/folder/folderCreate.gql'
const SHOW_HIDDEN = 'vue-ui.show-hidden-folders'
export default {
setup (props, { refs }) {
const { networkState } = useNetworkState()
// Folder
const { currentFolderData } = usecurrentFolderData(networkState)
const folderNavigationFeature = useFolderNavigation({
networkState,
currentFolderData
refs,
})
const { favoriteFolders, toggleFavorite } = useFavoriteFolders(currentFolderData)
const { showHiddenFolders } = useHiddenFolders()
const createFolderFeature = useCreateFolder(folderNavigationFeature.openFolder)
// Current working directory
const { updateOnCwdChanged } = useCwd()
// Utils
const { slicePath } = usePathUtils()
return {
networkState,
currentFolderData,
...folderNavigationFeature,
refreshFolder,
favoriteFolders,
toggleFavorite,
showHiddenFolders,
...createFolderFeature,
updateOnCwdChanged,
slicePath,
}
}
}
function useNetworkState () {
const networkState = state({
loading: 0,
error: false,
})
return {
networkState
}
}
function usecurrentFolderData (networkState) {
const currentFolderData = useQuery({
query: FOLDER_CURRENT,
fetchPolicy: 'networkState-only',
networkState,
async result () {
await nextTick()
refs.folders.scrollTop = 0
}
}, {})
return {
currentFolderData
}
}
function useFolderNavigation ({ networkState, currentFolderData, refs }) {
// Path editing
const pathEditing = state({
editingPath: false,
editedPath: '',
})
async function openPathEdit () {
pathEditing.editedPath = currentFolderData.path
pathEditing.editingPath = true
await nextTick()
refs.pathInput.focus()
}
function submitPathEdit () {
openFolder(pathEditing.editedPath)
}
// Folder opening
const openFolder = async (path) => {
pathEditing.editingPath = false
networkState.error = null
networkState.loading++
try {
await mutate({
mutation: FOLDER_OPEN,
variables: {
path
},
update: (store, { data: { folderOpen } }) => {
store.writeQuery({ query: FOLDER_CURRENT, data: { currentFolderData: folderOpen } })
}
})
} catch (e) {
networkState.error = e
}
networkState.loading--
}
async function openParentFolder () {
pathEditing.editingPath = false
networkState.error = null
networkState.loading++
try {
await mutate({
mutation: FOLDER_OPEN_PARENT,
update: (store, { data: { folderOpenParent } }) => {
store.writeQuery({ query: FOLDER_CURRENT, data: { currentFolderData: folderOpenParent } })
}
})
} catch (e) {
networkState.error = e
}
networkState.loading--
}
// Refresh
function refreshFolder () {
openFolder(currentFolderData.path)
}
return {
pathEditing,
openPathEdit,
submitPathEdit,
openFolder,
openParentFolder,
refreshFolder
}
}
function useFavoriteFolders (currentFolderData) {
const favoriteFolders = useQuery(FOLDERS_FAVORITE, [])
async function toggleFavorite () {
await mutate({
mutation: FOLDER_SET_FAVORITE,
variables: {
path: currentFolderData.path,
favorite: !currentFolderData.favorite
},
update: (store, { data: { folderSetFavorite } }) => {
store.writeQuery({ query: FOLDER_CURRENT, data: { currentFolderData: folderSetFavorite } })
let data = store.readQuery({ query: FOLDERS_FAVORITE })
// TODO this is a workaround
// See: https://github.com/apollographql/apollo-client/issues/4031#issuecomment-433668473
data = {
favoriteFolders: data.favoriteFolders.slice()
}
if (folderSetFavorite.favorite) {
data.favoriteFolders.push(folderSetFavorite)
} else {
const index = data.favoriteFolders.findIndex(
f => f.path === folderSetFavorite.path
)
index !== -1 && data.favoriteFolders.splice(index, 1)
}
store.writeQuery({ query: FOLDERS_FAVORITE, data })
}
})
}
return {
favoriteFolders,
toggleFavorite
}
}
function useHiddenFolders () {
const showHiddenFolders = value(localStorage.getItem(SHOW_HIDDEN) === 'true')
watch(showHiddenFolders, value => {
if (value) {
localStorage.setItem(SHOW_HIDDEN, 'true')
} else {
localStorage.removeItem(SHOW_HIDDEN)
}
}, { lazy: true })
return {
showHiddenFolders
}
}
function useCwd () {
async function resetProjectCwd () {
await mutate({
mutation: PROJECT_CWD_RESET
})
}
onBeforeRouteLeave((to, from, next) => {
if (to.matched.some(m => m.meta.needProject)) {
resetProjectCwd()
}
next()
})
// Update apollo cache
const updateOnCwdChanged = (previousResult, { subscriptionData }) => {
return {
cwd: subscriptionData.data.cwd
}
}
return {
updateOnCwdChanged
}
}
function useCreateFolder (openFolder) {
const showNewFolder = value(false)
const newFolderName = value('')
const newFolderValid = computed(() => isValidMultiName(newFolderName.value))
async function createFolder () {
if (!newFolderValid.value) return
const result = await mutate({
mutation: FOLDER_CREATE,
variables: {
name: newFolderName.value
}
})
openFolder(result.data.folderCreate.path)
newFolderName.value = ''
showNewFolder.value = false
}
return {
showNewFolder,
newFolderName,
newFolderValid,
createFolder
}
}
function usePathUtils () {
const slicePath = (path) => {
const parts = []
let startIndex = 0
let index
function findSeparator () {
index = path.indexOf('/', startIndex)
if (index === -1) index = path.indexOf('\\', startIndex)
return index !== -1
}
const addPart = index => {
const folder = path.substring(startIndex, index)
const slice = path.substring(0, index + 1)
parts.push({
name: folder,
path: slice
})
}
while (findSeparator()) {
addPart(index)
startIndex = index + 1
}
if (startIndex < path.length) addPart(path.length)
return parts
}
return {
slicePath
}
}
</script>
<script>
import { useQuery, mutate } from 'vue-apollo'
import { nextTick, state, value, watch } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
// Reusable functions not specific to this component
import { useNetworkState } from '@/functions/network'
import { usePathUtils } from '@/functions/path'
import { resetCwdOnLeave, useCwdUtils } from '@/functions/cwd'
// GraphQL
import FOLDER_CURRENT from '@/graphql/folder/folderCurrent.gql'
import FOLDERS_FAVORITE from '@/graphql/folder/favoriteFolders.gql'
import FOLDER_OPEN from '@/graphql/folder/folderOpen.gql'
import FOLDER_OPEN_PARENT from '@/graphql/folder/folderOpenParent.gql'
import FOLDER_SET_FAVORITE from '@/graphql/folder/folderSetFavorite.gql'
import PROJECT_CWD_RESET from '@/graphql/project/projectCwdReset.gql'
import FOLDER_CREATE from '@/graphql/folder/folderCreate.gql'
// Misc
import { isValidMultiName } from '@/util/folders'
const SHOW_HIDDEN = 'vue-ui.show-hidden-folders'
export default {
setup (props, { refs }) {
const { networkState } = useNetworkState()
// Folder
const { currentFolderData } = usecurrentFolderData(networkState)
const folderNavigationFeature = useFolderNavigation({
networkState,
currentFolderData
refs,
})
const { favoriteFolders, toggleFavorite } = useFavoriteFolders(currentFolderData)
const { showHiddenFolders } = useHiddenFolders()
const createFolderFeature = useCreateFolder(folderNavigation.openFolder)
// Current working directory
resetCwdOnLeave()
const { updateOnCwdChanged } = useCwdUtils()
// Utils
const { slicePath } = usePathUtils()
return {
networkState,
currentFolderData,
...folderNavigationFeature,
refreshFolder,
favoriteFolders,
toggleFavorite,
showHiddenFolders,
...createFolderFeature,
updateOnCwdChanged,
slicePath,
}
}
}
// Reusable functions specific to this component
export function usecurrentFolderData (networkState) {
const currentFolderData = useQuery({
query: FOLDER_CURRENT,
fetchPolicy: 'networkState-only',
networkState,
async result () {
await nextTick()
refs.folders.scrollTop = 0
}
}, {})
return {
currentFolderData
}
export }
export function useFolderNavigation ({ networkState, currentFolderData, refs }) {
// Path editing
const pathEditing = state({
editingPath: false,
editedPath: '',
})
async function openPathEdit () {
pathEditing.editedPath = currentFolderData.path
pathEditing.editingPath = true
await nextTick()
refs.pathInput.focus()
}
function submitPathEdit () {
openFolder(pathEditing.editedPath)
}
// Folder opening
const openFolder = async (path) => {
pathEditing.editingPath = false
networkState.error = null
networkState.loading++
try {
await mutate({
mutation: FOLDER_OPEN,
variables: {
path
},
update: (store, { data: { folderOpen } }) => {
store.writeQuery({ query: FOLDER_CURRENT, data: { currentFolderData: folderOpen } })
}
})
} catch (e) {
networkState.error = e
}
networkState.loading--
}
async function openParentFolder () {
pathEditing.editingPath = false
networkState.error = null
networkState.loading++
try {
await mutate({
mutation: FOLDER_OPEN_PARENT,
update: (store, { data: { folderOpenParent } }) => {
store.writeQuery({ query: FOLDER_CURRENT, data: { currentFolderData: folderOpenParent } })
}
})
} catch (e) {
networkState.error = e
}
networkState.loading--
}
// Refresh
function refreshFolder () {
openFolder(currentFolderData.path)
}
return {
pathEditing,
openPathEdit,
submitPathEdit,
openFolder,
openParentFolder,
refreshFolder
}
}
export function useFavoriteFolders (currentFolderData) {
const favoriteFolders = useQuery(FOLDERS_FAVORITE, [])
async function toggleFavorite () {
await mutate({
mutation: FOLDER_SET_FAVORITE,
variables: {
path: currentFolderData.path,
favorite: !currentFolderData.favorite
},
update: (store, { data: { folderSetFavorite } }) => {
store.writeQuery({ query: FOLDER_CURRENT, data: { currentFolderData: folderSetFavorite } })
let data = store.readQuery({ query: FOLDERS_FAVORITE })
// TODO this is a workaround
// See: https://github.com/apollographql/apollo-client/issues/4031#issuecomment-433668473
data = {
favoriteFolders: data.favoriteFolders.slice()
}
if (folderSetFavorite.favorite) {
data.favoriteFolders.push(folderSetFavorite)
} else {
const index = data.favoriteFolders.findIndex(
f => f.path === folderSetFavorite.path
)
index !== -1 && data.favoriteFolders.splice(index, 1)
}
store.writeQuery({ query: FOLDERS_FAVORITE, data })
}
})
}
return {
favoriteFolders,
toggleFavorite
}
}
export function useHiddenFolders () {
const showHiddenFolders = value(localStorage.getItem(SHOW_HIDDEN) === 'true')
watch(showHiddenFolders, value => {
if (value) {
localStorage.setItem(SHOW_HIDDEN, 'true')
} else {
localStorage.removeItem(SHOW_HIDDEN)
}
}, { lazy: true })
return {
showHiddenFolders
}
}
export function useCreateFolder (openFolder) {
const showNewFolder = value(false)
const newFolderName = value('')
const newFolderValid = computed(() => isValidMultiName(newFolderName.value))
async function createFolder () {
if (!newFolderValid.value) return
const result = await mutate({
mutation: FOLDER_CREATE,
variables: {
name: newFolderName.value
}
})
openFolder(result.data.folderCreate.path)
newFolderName.value = ''
showNewFolder.value = false
}
return {
showNewFolder,
newFolderName,
newFolderValid,
createFolder
}
}
</script>
@martinsotirov
Copy link

My opinion is that you are mostly doing something closer to separation of technologies instead.

No, I am talking about separation of concerns and single responsibility principle – this means that, for example, changing styles, shouldn't force you to update the files where business logic resides or changing something about the database shouldn't force you to change styles or views and so on. You might want to read up on Design Patterns, the SOLID principle, Domain Driven Development etc. These are basic programming concepts, I'm not talking about anything new here...

One of the major benefits of GraphQL and Apollo is co-locating data requirements in the relevant components themselves.

This absolutely doesn't solve the problem I described. What happens if you decide to refactor to a different library instead of Apollo? You still have to change a bunch of unrelated files. The view shouldn't care how or where the data comes from.

Isn't the conversation going a little bit off-topic here?

Yes, I see that we are talking past each other so I am leaving.

@Akryum
Copy link
Author

Akryum commented Jun 25, 2019

shouldn't force you to update the files where business logic resides or changing something about the database shouldn't force you to change styles or views and so on.

Editing a file shouldn't be a metric of how good you separate concerns. Maybe a component-based architecture isn't the right abstraction for you. Because in a component architecture, components should be as self-sufficient and encapsulated as possible, so that concerned are cleanly separated and maintainability is as high as possible.

What happens if you decide to refactor to a different library instead of Apollo?

So you are saying you would have to edit dozens of JavaScript file instead of dozens of Vue files. There is not much difference. The more I read you, the more I'm convinced you are mixing up separation of concerns and separation of technologies. Maybe I'm wrong, I don't know the projects you are working on.

You still have to change a bunch of unrelated files.

Those files are related, they are components of your application. But at the same time they each (should) have their own responsibilities, at user-experience level.

@martinsotirov
Copy link

martinsotirov commented Jun 25, 2019

Editing a file shouldn't be a metric of how good you separate concerns.

That's literally the definition of Single Responsibility Principle:

As an example, consider a module that compiles and prints a report. Imagine such a module can be changed for two reasons. First, the content of the report could change. Second, the format of the report could change. These two things change for very different causes; one substantive, and one cosmetic. The single responsibility principle says that these two aspects of the problem are really two separate responsibilities, and should therefore be in separate classes or modules.

I see where the problem stems from.

The more I read you, the more I'm convinced you are mixing up separation of concerns and separation of technologies.

Those files are related, they are components of your application. But at the same time they each (should) have their own responsibilities, at user-experience level.

You are mixing up separation of concerns with domain driven development, where code related to the same functionality is grouped together in the same module, not file).

Here's the definition of Separation of Concerns from Wikipedia:

Modularity, and hence separation of concerns, is achieved by encapsulating information inside a section of code that has a well-defined interface. Encapsulation is a means of information hiding. Layered designs in information systems are another embodiment of separation of concerns (e.g., presentation layer, business logic layer, data access layer, persistence layer).

E.g. a button in your UI shouldn't directly know about the database.

That in no way negates the use case for component architecture. Only you are abusing them by stuffing them with logic that should not be there.

This is from the Vue docs:

Vue (pronounced /vjuː/, like view) is a progressive framework for building user interfaces.

@beeplin
Copy link

beeplin commented Jun 25, 2019

@Akryum @martinsotirov sorry to bother. My 2 cents.

As a full-stack developer, I feel development for web UI has something fundamentally different from traditional backend development. The backend world can be perfectly divided in some purely logical and self-containing layers: web API, business logic, shared services, data access layer, database ... And together with division of logical layers comes very common division of labor. Different people in charge of different tasks. So we have OOP, single responsibility principle, DDD ...

But in web frontend it seems too heavy to apply all the traditional software engineering doctrines. Most UI components are something quite special: relatively simple business logic (since heavy business logic always goes to backend), huge view layer (DOM API, vue template, react jsx...), and more importantly, high frequency of behavior changing. I guess more than 90% of frontend code is about:

  • fetching data from backend
  • restructuring fetched data to feed to UI
  • rendering UI
  • handling UI input events

most of which are heavily entangled with the UI framework (vue & quasar? react & material design? ...) and the API framework (rpc? rest? graphql & apollo?) and are changing very quickly and frequently due to the ever swinging/evolving requirements from product team.

So after trying MVC/MVVM pattern for some time, now I kinda agree with @Akryum and choose to use vue SFC and vue-apollo(graphql), making vue component as my basic self-containing logic unit: a component is like a class instance from the OOP view, having its own states, behavior (including data fetching, css styles, UI rendering, etc.). All in one component. (EDIT: unlike what @Akryum does above, I even tend not to import gql files into components, but rather to write const gql strings directly in the method bodies of components, in order to make it clear what kind of queries/mutations I am doing here.)

Yes, this leads to heavy framework binding. it is hard to transfer from vue to react, or from apollo to rest. But, anyway there are no that much business logics in the frontend, therefore even if we had carefully designed a framework-independent architecture and had extracted all pure business logics into service files ... we would found that when we really tried, for example, to switch vue to react, there weren't that much codes that can be reused. We basically always had to rewrite the frontend part from scratch ;P

From that aspect I think the new composition function pattern is quite enough, and in fact quite suitable, for what I know as frontend development.

@Akryum
Copy link
Author

Akryum commented Jun 25, 2019

EDIT: unlike what @Akryum does above, I even tend not to import gql files into components, but rather to write const gql strings directly in the method bodies of components, in order to make it clear what kind of queries/mutations I am doing here.

I do that now too, the example above is pending some refactoring in vue-cli-ui. 😸

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment