From a319da53754c9f8e564681de7c167f70c3def4a0 Mon Sep 17 00:00:00 2001 From: mazaaxi <22339605+mazaaxi@users.noreply.github.com> Date: Sun, 23 Oct 2022 19:50:03 +0900 Subject: [PATCH] Implemented around `pages` refs: ["VUE3-BP-T-5"] --- env.d.ts | 2 + src/App.vue | 92 ----- src/assets/base.css | 74 ---- src/assets/main.css | 35 -- src/components/HelloWorld.vue | 40 -- src/components/TheWelcome.vue | 86 ---- src/components/WelcomeItem.vue | 86 ---- src/components/__tests__/HelloWorld.spec.ts | 10 - src/components/icons/IconCommunity.vue | 7 - src/components/icons/IconDocumentation.vue | 7 - src/components/icons/IconEcosystem.vue | 7 - src/components/icons/IconSupport.vue | 7 - src/components/icons/IconTooling.vue | 19 - src/main.ts | 10 +- src/pages/app/AppPage.vue | 316 +++++++++++++++ src/pages/app/index.ts | 2 + src/pages/examples/abc/ABCPage.vue | 50 +++ src/pages/examples/abc/ABCViewPC.vue | 145 +++++++ src/pages/examples/abc/ABCViewSP.vue | 136 +++++++ src/pages/examples/abc/BaseABCView.ts | 145 +++++++ src/pages/examples/abc/MessageInput.vue | 138 +++++++ src/pages/examples/abc/index.ts | 2 + .../MiniatureProjectPage.vue | 348 +++++++++++++++++ src/pages/examples/miniature-project/index.ts | 2 + .../examples/miniature-project/services.ts | 368 ++++++++++++++++++ src/pages/examples/routing/RoutingPage.vue | 265 +++++++++++++ src/pages/examples/routing/index.ts | 2 + src/pages/home/HomePage.vue | 78 ++++ src/pages/home/index.ts | 2 + src/pages/shop/ShopPage.vue | 343 ++++++++++++++++ src/pages/shop/index.ts | 2 + src/quasar-options.ts | 1 - src/router/base.ts | 97 +++++ src/router/index.ts | 72 +++- src/router/routes/examples.ts | 295 ++++++++++++++ src/router/routes/home.ts | 18 +- src/router/routes/{about.ts => shop.ts} | 26 +- src/tests/helpers/services/index.ts | 52 ++- .../unit/pages/examples/abc/ABCViewPC.spec.ts | 82 ++++ src/views/AboutView.vue | 52 --- src/views/HomeView.vue | 9 - vite.config.ts | 7 +- 42 files changed, 2938 insertions(+), 599 deletions(-) delete mode 100644 src/App.vue delete mode 100644 src/assets/base.css delete mode 100644 src/assets/main.css delete mode 100644 src/components/HelloWorld.vue delete mode 100644 src/components/TheWelcome.vue delete mode 100644 src/components/WelcomeItem.vue delete mode 100644 src/components/__tests__/HelloWorld.spec.ts delete mode 100644 src/components/icons/IconCommunity.vue delete mode 100644 src/components/icons/IconDocumentation.vue delete mode 100644 src/components/icons/IconEcosystem.vue delete mode 100644 src/components/icons/IconSupport.vue delete mode 100644 src/components/icons/IconTooling.vue create mode 100644 src/pages/app/AppPage.vue create mode 100644 src/pages/app/index.ts create mode 100644 src/pages/examples/abc/ABCPage.vue create mode 100644 src/pages/examples/abc/ABCViewPC.vue create mode 100644 src/pages/examples/abc/ABCViewSP.vue create mode 100644 src/pages/examples/abc/BaseABCView.ts create mode 100644 src/pages/examples/abc/MessageInput.vue create mode 100644 src/pages/examples/abc/index.ts create mode 100644 src/pages/examples/miniature-project/MiniatureProjectPage.vue create mode 100644 src/pages/examples/miniature-project/index.ts create mode 100644 src/pages/examples/miniature-project/services.ts create mode 100644 src/pages/examples/routing/RoutingPage.vue create mode 100644 src/pages/examples/routing/index.ts create mode 100644 src/pages/home/HomePage.vue create mode 100644 src/pages/home/index.ts create mode 100644 src/pages/shop/ShopPage.vue create mode 100644 src/pages/shop/index.ts create mode 100644 src/router/base.ts create mode 100644 src/router/routes/examples.ts rename src/router/routes/{about.ts => shop.ts} (62%) create mode 100644 src/tests/unit/pages/examples/abc/ABCViewPC.spec.ts delete mode 100644 src/views/AboutView.vue delete mode 100644 src/views/HomeView.vue diff --git a/env.d.ts b/env.d.ts index d3d6cf0..81ebd35 100644 --- a/env.d.ts +++ b/env.d.ts @@ -17,10 +17,12 @@ interface ImportMeta { } import type { Constants, Screen } from '@/base' +import { AppRoutes } from '@/router' declare module '@vue/runtime-core' { export interface ComponentCustomProperties { $constants: Constants + $routes: AppRoutes $screen: Screen } } diff --git a/src/App.vue b/src/App.vue deleted file mode 100644 index 669bc57..0000000 --- a/src/App.vue +++ /dev/null @@ -1,92 +0,0 @@ - - - - - diff --git a/src/assets/base.css b/src/assets/base.css deleted file mode 100644 index 71dc55a..0000000 --- a/src/assets/base.css +++ /dev/null @@ -1,74 +0,0 @@ -/* color palette from */ -:root { - --vt-c-white: #ffffff; - --vt-c-white-soft: #f8f8f8; - --vt-c-white-mute: #f2f2f2; - - --vt-c-black: #181818; - --vt-c-black-soft: #222222; - --vt-c-black-mute: #282828; - - --vt-c-indigo: #2c3e50; - - --vt-c-divider-light-1: rgba(60, 60, 60, 0.29); - --vt-c-divider-light-2: rgba(60, 60, 60, 0.12); - --vt-c-divider-dark-1: rgba(84, 84, 84, 0.65); - --vt-c-divider-dark-2: rgba(84, 84, 84, 0.48); - - --vt-c-text-light-1: var(--vt-c-indigo); - --vt-c-text-light-2: rgba(60, 60, 60, 0.66); - --vt-c-text-dark-1: var(--vt-c-white); - --vt-c-text-dark-2: rgba(235, 235, 235, 0.64); -} - -/* semantic color variables for this project */ -:root { - --color-background: var(--vt-c-white); - --color-background-soft: var(--vt-c-white-soft); - --color-background-mute: var(--vt-c-white-mute); - - --color-border: var(--vt-c-divider-light-2); - --color-border-hover: var(--vt-c-divider-light-1); - - --color-heading: var(--vt-c-text-light-1); - --color-text: var(--vt-c-text-light-1); - - --section-gap: 160px; -} - -@media (prefers-color-scheme: dark) { - :root { - --color-background: var(--vt-c-black); - --color-background-soft: var(--vt-c-black-soft); - --color-background-mute: var(--vt-c-black-mute); - - --color-border: var(--vt-c-divider-dark-2); - --color-border-hover: var(--vt-c-divider-dark-1); - - --color-heading: var(--vt-c-text-dark-1); - --color-text: var(--vt-c-text-dark-2); - } -} - -*, -*::before, -*::after { - box-sizing: border-box; - margin: 0; - position: relative; - font-weight: normal; -} - -body { - min-height: 100vh; - color: var(--color-text); - background: var(--color-background); - transition: color 0.5s, background-color 0.5s; - line-height: 1.6; - font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, - Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; - font-size: 15px; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} diff --git a/src/assets/main.css b/src/assets/main.css deleted file mode 100644 index c133f91..0000000 --- a/src/assets/main.css +++ /dev/null @@ -1,35 +0,0 @@ -@import "./base.css"; - -#app { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - - font-weight: normal; -} - -a, -.green { - text-decoration: none; - color: hsla(160, 100%, 37%, 1); - transition: 0.4s; -} - -@media (hover: hover) { - a:hover { - background-color: hsla(160, 100%, 37%, 0.2); - } -} - -@media (min-width: 1024px) { - body { - display: flex; - place-items: center; - } - - #app { - display: grid; - grid-template-columns: 1fr 1fr; - padding: 0 2rem; - } -} diff --git a/src/components/HelloWorld.vue b/src/components/HelloWorld.vue deleted file mode 100644 index 23afedd..0000000 --- a/src/components/HelloWorld.vue +++ /dev/null @@ -1,40 +0,0 @@ - - - - - diff --git a/src/components/TheWelcome.vue b/src/components/TheWelcome.vue deleted file mode 100644 index c9cf589..0000000 --- a/src/components/TheWelcome.vue +++ /dev/null @@ -1,86 +0,0 @@ - - - diff --git a/src/components/WelcomeItem.vue b/src/components/WelcomeItem.vue deleted file mode 100644 index ba0def3..0000000 --- a/src/components/WelcomeItem.vue +++ /dev/null @@ -1,86 +0,0 @@ - - - diff --git a/src/components/__tests__/HelloWorld.spec.ts b/src/components/__tests__/HelloWorld.spec.ts deleted file mode 100644 index c959de0..0000000 --- a/src/components/__tests__/HelloWorld.spec.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { describe, expect, it } from 'vitest' -import HelloWorld from '@/components/HelloWorld.vue' -import { mount } from '@vue/test-utils' - -describe('HelloWorld', () => { - it('renders properly', () => { - const wrapper = mount(HelloWorld, { props: { msg: 'Hello Vitest' } }) - expect(wrapper.text()).toContain('Hello Vitest') - }) -}) diff --git a/src/components/icons/IconCommunity.vue b/src/components/icons/IconCommunity.vue deleted file mode 100644 index 2dc8b05..0000000 --- a/src/components/icons/IconCommunity.vue +++ /dev/null @@ -1,7 +0,0 @@ - diff --git a/src/components/icons/IconDocumentation.vue b/src/components/icons/IconDocumentation.vue deleted file mode 100644 index 6d4791c..0000000 --- a/src/components/icons/IconDocumentation.vue +++ /dev/null @@ -1,7 +0,0 @@ - diff --git a/src/components/icons/IconEcosystem.vue b/src/components/icons/IconEcosystem.vue deleted file mode 100644 index c3a4f07..0000000 --- a/src/components/icons/IconEcosystem.vue +++ /dev/null @@ -1,7 +0,0 @@ - diff --git a/src/components/icons/IconSupport.vue b/src/components/icons/IconSupport.vue deleted file mode 100644 index 7452834..0000000 --- a/src/components/icons/IconSupport.vue +++ /dev/null @@ -1,7 +0,0 @@ - diff --git a/src/components/icons/IconTooling.vue b/src/components/icons/IconTooling.vue deleted file mode 100644 index 660598d..0000000 --- a/src/components/icons/IconTooling.vue +++ /dev/null @@ -1,19 +0,0 @@ - - diff --git a/src/main.ts b/src/main.ts index 0061d4b..f060b8f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,8 +1,6 @@ -/* eslint-disable sort-imports */ - import { setupI18n, useI18nUtils } from '@/i18n' import { useConstants, useScreen } from '@/base' -import App from './App.vue' +import AppPage from './pages/app' import { Quasar } from 'quasar' import { createApp } from 'vue' import quasarOptions from '@/quasar-options' @@ -14,17 +12,17 @@ async function init() { setupConfig() const i18n = setupI18n() await useI18nUtils().loadI18nLocaleMessages() - const router = setupRouter() + const router = setupRouter(i18n) setupServices() const constants = useConstants() const screen = useScreen() - const app = createApp(App) + const app = createApp(AppPage) .use(Quasar, quasarOptions) .use(i18n) - .use(constants) .use(router) .use(router.routes) + .use(constants) .use(screen) .mount('#app') } diff --git a/src/pages/app/AppPage.vue b/src/pages/app/AppPage.vue new file mode 100644 index 0000000..dd5bed4 --- /dev/null +++ b/src/pages/app/AppPage.vue @@ -0,0 +1,316 @@ + + + + + diff --git a/src/pages/app/index.ts b/src/pages/app/index.ts new file mode 100644 index 0000000..e91bd20 --- /dev/null +++ b/src/pages/app/index.ts @@ -0,0 +1,2 @@ +import AppPage from '@/pages/app/AppPage.vue' +export default AppPage diff --git a/src/pages/examples/abc/ABCPage.vue b/src/pages/examples/abc/ABCPage.vue new file mode 100644 index 0000000..fca787f --- /dev/null +++ b/src/pages/examples/abc/ABCPage.vue @@ -0,0 +1,50 @@ + + + diff --git a/src/pages/examples/abc/ABCViewPC.vue b/src/pages/examples/abc/ABCViewPC.vue new file mode 100644 index 0000000..2ad9611 --- /dev/null +++ b/src/pages/examples/abc/ABCViewPC.vue @@ -0,0 +1,145 @@ + + + + + diff --git a/src/pages/examples/abc/ABCViewSP.vue b/src/pages/examples/abc/ABCViewSP.vue new file mode 100644 index 0000000..1da455c --- /dev/null +++ b/src/pages/examples/abc/ABCViewSP.vue @@ -0,0 +1,136 @@ + + + + + diff --git a/src/pages/examples/abc/BaseABCView.ts b/src/pages/examples/abc/BaseABCView.ts new file mode 100644 index 0000000..0956755 --- /dev/null +++ b/src/pages/examples/abc/BaseABCView.ts @@ -0,0 +1,145 @@ +import { computed, nextTick, onMounted, reactive, ref, toRefs, watch, watchEffect } from 'vue' +import MessageInput from '@/pages/examples/abc/MessageInput.vue' +import type { QInput } from 'quasar' +import type { SetupContext } from 'vue' +import { useDialogs } from '@/dialogs' +import { useRouter } from '@/router' +import { useServices } from '@/services' + +//========================================================================== +// +// Interfaces +// +//========================================================================== + +interface BaseAbcView extends BaseAbcView.Props {} + +//========================================================================== +// +// Implementation +// +//========================================================================== + +namespace BaseAbcView { + export interface Props {} + + export const components = { + MessageInput, + } + + export function setup(props: Props, context: SetupContext) { + //---------------------------------------------------------------------- + // + // Lifecycle hooks + // + //---------------------------------------------------------------------- + + onMounted(() => { + const { title, body } = route.message + message.title = title || '' + message.body = body || '' + }) + + //---------------------------------------------------------------------- + // + // Variables + // + //---------------------------------------------------------------------- + + const services = useServices() + const router = useRouter() + const route = router.routes.examples.abc + const dialogs = useDialogs() + + const isSignedIn = computed(() => services.account.isSignedIn) + + // see below for why we use `toRefs`: + // https://v3.vuejs.org/guide/reactivity-fundamentals.html#destructuring-reactive-state + const user = reactive({ + ...toRefs(services.account.user), + fullName: computed(() => `${services.account.user.first} ${services.account.user.last}`), + }) + + const messageInput = ref() + const logInput = ref() + + const message = reactive({ title: '', body: '' }) + + const sentMessages = reactive<{ [uid: string]: string[] }>({}) + + const sentMessagesLog = ref('') + + const displayMessage = computed(() => messageInput.value?.displayMessage || '') + + const reversedMessage = computed(() => message.body.split('').reverse().join('')) + + const doubleReversedMessage = computed(() => reversedMessage.value.split('').reverse().join('')) + + const watchEffectMessage = ref('') + + //---------------------------------------------------------------------- + // + // Events + // + //---------------------------------------------------------------------- + + watchEffect(() => { + watchEffectMessage.value = message.body + }) + + watch( + () => sentMessages, + (newValue, oldValue) => { + const latestMessage = sentMessages[user.id][0] + sentMessagesLog.value = `[${user.id}] ${latestMessage}\n${sentMessagesLog.value}` + nextTick(() => { + const inputEl = logInput.value!.getNativeElement() as HTMLTextAreaElement + inputEl.scrollTop = inputEl.scrollHeight + }) + }, + { deep: true } + ) + + async function signInOrOutButtonOnClick() { + if (isSignedIn.value) { + await services.account.signOut() + } else { + dialogs.signIn.open() + } + } + + const sendButtonOnClick = () => { + sentMessages[user.id] = sentMessages[user.id] ?? [] + sentMessages[user.id].unshift(displayMessage.value) + } + + //---------------------------------------------------------------------- + // + // Result + // + //---------------------------------------------------------------------- + + return { + messageInput, + logInput, + isSignedIn, + user, + message, + sentMessagesLog, + displayMessage, + reversedMessage, + doubleReversedMessage, + watchEffectMessage, + signInOrOutButtonOnClick, + sendButtonOnClick, + } + } +} + +//========================================================================== +// +// Export +// +//========================================================================== + +export { BaseAbcView } diff --git a/src/pages/examples/abc/MessageInput.vue b/src/pages/examples/abc/MessageInput.vue new file mode 100644 index 0000000..b7dfc9c --- /dev/null +++ b/src/pages/examples/abc/MessageInput.vue @@ -0,0 +1,138 @@ + + + + + diff --git a/src/pages/examples/abc/index.ts b/src/pages/examples/abc/index.ts new file mode 100644 index 0000000..709d307 --- /dev/null +++ b/src/pages/examples/abc/index.ts @@ -0,0 +1,2 @@ +import AbcPage from '@/pages/examples/abc/ABCPage.vue' +export default AbcPage diff --git a/src/pages/examples/miniature-project/MiniatureProjectPage.vue b/src/pages/examples/miniature-project/MiniatureProjectPage.vue new file mode 100644 index 0000000..3ab9b29 --- /dev/null +++ b/src/pages/examples/miniature-project/MiniatureProjectPage.vue @@ -0,0 +1,348 @@ + + + + + diff --git a/src/pages/examples/miniature-project/index.ts b/src/pages/examples/miniature-project/index.ts new file mode 100644 index 0000000..9ef103c --- /dev/null +++ b/src/pages/examples/miniature-project/index.ts @@ -0,0 +1,2 @@ +import MiniatureProjectPage from '@/pages/examples/miniature-project/MiniatureProjectPage.vue' +export default MiniatureProjectPage diff --git a/src/pages/examples/miniature-project/services.ts b/src/pages/examples/miniature-project/services.ts new file mode 100644 index 0000000..5bb7a38 --- /dev/null +++ b/src/pages/examples/miniature-project/services.ts @@ -0,0 +1,368 @@ +import type { ComputedRef, Ref, UnwrapNestedRefs } from 'vue' +import type { DeepPartial, DeepReadonly, DeepUnreadonly, RequiredAre } from 'js-common-lib' +import { computed, reactive, ref } from 'vue' +import { isImplemented, pickProps, sleep } from 'js-common-lib' +import type { ItemsChangeType } from '@/services' +import type { Unsubscribe } from 'nanoevents' +import { createNanoEvents } from 'nanoevents' +import { createObjectCopyFunctions } from '@/base' +import { generateId } from '@/services' +import rfdc from 'rfdc' +const cloneDeep = rfdc() + +//========================================================================== +// +// Entities +// +//========================================================================== + +interface User { + id: string + first: string + last: string + age: number +} + +namespace User { + export const { populate, clone } = createObjectCopyFunctions((to, from) => { + if (typeof from.id === 'string') to.id = from.id + if (typeof from.first === 'string') to.first = from.first + if (typeof from.last === 'string') to.last = from.last + if (typeof from.age === 'number') to.age = from.age + return to + }) +} + +//========================================================================== +// +// APIs +// +//========================================================================== + +//-------------------------------------------------------------------------- +// APIs +//-------------------------------------------------------------------------- + +interface APIs { + getAllUsers(): Promise + addUser(user: User): Promise + setUser(user: RequiredAre, 'id'>): Promise + removeUser(id: string): Promise +} + +namespace APIs { + const users: User[] = [] + for (let i = 1; i <= 3; i++) { + users.push({ + id: generateId(), + first: 'First' + i.toString().padStart(5, '0'), + last: 'Last' + i.toString().padStart(5, '0'), + age: Math.floor(Math.random() * 101) + 0, + }) + } + + let instance: APIs + + export function setupAPIs(): APIs { + instance = newInstance() + return instance + } + + export function useAPIs(): APIs { + return instance + } + + export function newInstance() { + const getAllUsers: APIs['getAllUsers'] = async () => { + await sleep(500) + return cloneDeep(users) + } + + const addUser: APIs['addUser'] = async user => { + await sleep(500) + if (users.find(item => item.id === user.id)) { + throw new Error(`A user with the same id '${user.id}' already exists.`) + } + users.push(user) + return cloneDeep(user) + } + + const setUser: APIs['setUser'] = async user => { + await sleep(500) + const target = users.find(item => item.id === user.id) + if (!target) { + throw new Error(`The user with the specified id '${user.id}' does not exist.`) + } + return cloneDeep(Object.assign(target, user)) + } + + const removeUser: APIs['removeUser'] = async id => { + await sleep(500) + const index = users.findIndex(item => item.id === id) + if (index < 0) { + throw new Error(`The user with the specified id '${id}' does not exist.`) + } + const target = users.splice(index, 1)[0] + return cloneDeep(target) + } + + const result = { + getAllUsers, + addUser, + setUser, + removeUser, + } + + return isImplemented(result) + } +} + +const { setupAPIs, useAPIs } = APIs + +//========================================================================== +// +// Stores +// +//========================================================================== + +//-------------------------------------------------------------------------- +// Stores +//-------------------------------------------------------------------------- + +interface Stores { + readonly user: UserStore +} + +namespace Stores { + let instance: Stores + + export function setupStores(): Stores { + instance = { + user: UserStore.setupInstance(), + } + return instance + } + + export function useStores(): Stores { + return instance + } +} + +const { setupStores, useStores } = Stores + +//-------------------------------------------------------------------------- +// UserStore +//-------------------------------------------------------------------------- + +interface UserStore extends UnwrapNestedRefs {} + +interface WrapUserStore { + readonly all: DeepReadonly> + readonly averageAge: ComputedRef + get(id: string): User | undefined + add(user: User): User + set(user: RequiredAre, 'id'>): User + remove(uid: string): User +} + +namespace UserStore { + let instance: UserStore + + export function setupInstance(): UserStore { + instance = reactive(newWrapInstance()) + return instance + } + + export function newWrapInstance() { + const all = ref([]) + + const averageAge = computed(() => { + if (all.value.length === 0) return 0 + const totalAge = all.value.reduce((result, user) => result + user.age, 0) + return Math.round(totalAge / all.value.length) + }) + + const get: WrapUserStore['get'] = id => { + const user = all.value.find(user => user.id === id) + return cloneDeep(user) + } + + const add: WrapUserStore['add'] = user => { + if (get(user.id)) { + throw new Error(`A user with the same id '${user.id}' already exists.`) + } + + const newUser = pickProps(user, ['id', 'first', 'last', 'age']) + all.value.push(newUser) + + return cloneDeep(newUser) + } + + const set: WrapUserStore['set'] = user => { + const target = all.value.find(item => item.id === user.id) + if (!target) { + throw new Error(`The user with the specified id '${user.id}' does not exist.`) + } + + Object.assign(target, pickProps(user, ['id', 'first', 'last', 'age'])) + + return cloneDeep(target) + } + + const remove: WrapUserStore['remove'] = id => { + const index = all.value.findIndex(user => user.id === id) + if (index < 0) { + throw new Error(`The user with the specified id '${id}' does not exist.`) + } + + const target = all.value.splice(index, 1)[0] + + return cloneDeep(target) + } + + const result = { + all, + averageAge, + get, + add, + set, + remove, + } + + return isImplemented(result) + } +} + +//========================================================================== +// +// Services +// +//========================================================================== + +//-------------------------------------------------------------------------- +// Services +//-------------------------------------------------------------------------- + +interface Services { + readonly admin: AdminLogic +} + +namespace Services { + let instance: Services + + export function setupServices(): Services { + setupAPIs() + setupStores() + + instance = { + admin: AdminLogic.setupInstance(), + } + return instance + } + + export function useServices(): Services { + return instance + } +} + +const { setupServices, useServices } = Services + +//-------------------------------------------------------------------------- +// AdminLogic +//-------------------------------------------------------------------------- + +interface AdminLogic extends UnwrapNestedRefs {} + +interface WrapAdminLogic { + averageUserAge: ComputedRef + fetchUsers(): Promise + addUser(user: User): Promise + setUser(user: RequiredAre, 'id'>): Promise + removeUser(id: string): Promise + getAllUsers(): User[] + onUsersChange( + cb: (changeType: ItemsChangeType, newUser?: User, oldUser?: User) => void + ): Unsubscribe +} + +namespace AdminLogic { + let instance: AdminLogic + + export function setupInstance() { + instance = reactive(newWrapInstance()) + return instance + } + + export function newWrapInstance() { + const apis = useAPIs() + const stores = useStores() + const emitter = createNanoEvents<{ + usersChange: (changeType: ItemsChangeType, newUser?: User, oldUser?: User) => void + }>() + + const fetchUsers: WrapAdminLogic['fetchUsers'] = async () => { + const response = await apis.getAllUsers() + response.forEach(responseUser => { + const exists = stores.user.get(responseUser.id) + if (exists) { + const updated = stores.user.set(responseUser) + emitter.emit('usersChange', 'Update', updated, exists) + } else { + const added = stores.user.add(responseUser) + emitter.emit('usersChange', 'Add', added, undefined) + } + }) + } + + const addUser: WrapAdminLogic['addUser'] = async user => { + const response = await apis.addUser(user) + const added = stores.user.add(response) + emitter.emit('usersChange', 'Add', added, undefined) + return added + } + + const setUser: WrapAdminLogic['setUser'] = async user => { + const oldUser = stores.user.get(user.id) + const response = await apis.setUser(user) + const updated = stores.user.set(response) + emitter.emit('usersChange', 'Update', updated, oldUser) + return updated + } + + const removeUser: WrapAdminLogic['removeUser'] = async id => { + const response = await apis.removeUser(id) + const removed = stores.user.remove(response.id) + emitter.emit('usersChange', 'Remove', undefined, removed) + return removed + } + + const getAllUsers: WrapAdminLogic['getAllUsers'] = () => { + return User.clone(stores.user.all) + } + + const onUsersChange: WrapAdminLogic['onUsersChange'] = cb => { + return emitter.on('usersChange', cb) + } + + const result = { + averageUserAge: computed(() => stores.user.averageAge), + fetchUsers, + addUser, + setUser, + removeUser, + getAllUsers, + onUsersChange, + } + + return isImplemented(result) + } +} + +//========================================================================== +// +// Export +// +//========================================================================== + +export { setupServices, useServices } +export type { User } diff --git a/src/pages/examples/routing/RoutingPage.vue b/src/pages/examples/routing/RoutingPage.vue new file mode 100644 index 0000000..7a8764f --- /dev/null +++ b/src/pages/examples/routing/RoutingPage.vue @@ -0,0 +1,265 @@ + + + + + diff --git a/src/pages/examples/routing/index.ts b/src/pages/examples/routing/index.ts new file mode 100644 index 0000000..e4056a8 --- /dev/null +++ b/src/pages/examples/routing/index.ts @@ -0,0 +1,2 @@ +import RoutingPage from '@/pages/examples/routing/RoutingPage.vue' +export default RoutingPage diff --git a/src/pages/home/HomePage.vue b/src/pages/home/HomePage.vue new file mode 100644 index 0000000..c127ff0 --- /dev/null +++ b/src/pages/home/HomePage.vue @@ -0,0 +1,78 @@ + + + + + diff --git a/src/pages/home/index.ts b/src/pages/home/index.ts new file mode 100644 index 0000000..ca1a7c2 --- /dev/null +++ b/src/pages/home/index.ts @@ -0,0 +1,2 @@ +import HomePage from '@/pages/home/HomePage.vue' +export default HomePage diff --git a/src/pages/shop/ShopPage.vue b/src/pages/shop/ShopPage.vue new file mode 100644 index 0000000..4051805 --- /dev/null +++ b/src/pages/shop/ShopPage.vue @@ -0,0 +1,343 @@ + + + + + diff --git a/src/pages/shop/index.ts b/src/pages/shop/index.ts new file mode 100644 index 0000000..db119ff --- /dev/null +++ b/src/pages/shop/index.ts @@ -0,0 +1,2 @@ +import ShopPage from '@/pages/shop/ShopPage.vue' +export default ShopPage diff --git a/src/quasar-options.ts b/src/quasar-options.ts index 60037b2..8020b2a 100644 --- a/src/quasar-options.ts +++ b/src/quasar-options.ts @@ -4,7 +4,6 @@ import 'quasar/src/css/index.sass' import '@quasar/extras/roboto-font/roboto-font.css' import '@quasar/extras/material-icons/material-icons.css' import '@quasar/extras/fontawesome-v5/fontawesome-v5.css' -import '@/assets/main.css' import { Dialog, Loading, Notify } from 'quasar' diff --git a/src/router/base.ts b/src/router/base.ts new file mode 100644 index 0000000..d58d164 --- /dev/null +++ b/src/router/base.ts @@ -0,0 +1,97 @@ +import type { RouteInput, WrapRoute } from '@/router/core' +import { extensibleMethod, isImplemented } from 'js-common-lib' +import type { ComputedRef } from 'vue' +import { Route } from '@/router/core' + +//========================================================================== +// +// Interfaces +// +//========================================================================== + +const PathParamKeys = { + Locale: 'locale', +} as const + +interface BaseRouteInput extends RouteInput { + locale: ComputedRef +} + +//========================================================================== +// +// Implementation +// +//========================================================================== + +interface WrapBaseRoute extends WrapRoute { + readonly locale: ComputedRef +} + +namespace BaseRoute { + export function newWrapInstance(input: BaseRouteInput) { + //---------------------------------------------------------------------- + // + // Variables + // + //---------------------------------------------------------------------- + + const base = Route.newWrapInstance(input) + + const locale = input.locale + + //---------------------------------------------------------------------- + // + // Methods + // + //---------------------------------------------------------------------- + + const update = (base.update.body = extensibleMethod(async route => { + // if the parameter has a language + if (route.params[PathParamKeys.Locale]) { + await base.update.super(route) + } + // if the parameter does not have a language + // Note: it means that it is not locale routing + else { + base.clear() + } + })) + + const toPath = (base.toPath.body = extensibleMethod(input => { + const { routePath, params, query, hash } = input + // replace the language in `params` with the language selected by the application + // Note: Except at a start of the application, the order of processing is + // "change language" -> "change route". + return base.toPath.super({ + routePath, + params: { ...params, [PathParamKeys.Locale]: locale.value }, + query, + hash, + }) + })) + + //---------------------------------------------------------------------- + // + // Result + // + //---------------------------------------------------------------------- + + const result = { + ...base, + locale, + update, + toPath, + } + + return isImplemented(result) + } +} + +//========================================================================== +// +// Export +// +//========================================================================== + +export { BaseRoute } +export type { BaseRouteInput, WrapBaseRoute } diff --git a/src/router/index.ts b/src/router/index.ts index a5bd861..415982d 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -1,10 +1,13 @@ +import type { App, WritableComputedRef } from 'vue' import { Route, Router } from '@/router/core' -import { AboutRoute } from '@/router/routes/about' -import type { App } from 'vue' +import { SupportI18nLocales, useI18nUtils } from '@/i18n' +import { computed, reactive, watch } from 'vue' +import type { BaseRouteInput } from '@/router/base' +import { ExamplesRoutes } from '@/router/routes/examples' import { HomeRoute } from '@/router/routes/home' +import type { I18n } from 'vue-i18n' import type { RawRoute } from '@/router/core' -import type { RouteInput } from '@/router/core' -import { reactive } from 'vue' +import { ShopRoute } from '@/router/routes/shop' //========================================================================== // @@ -16,7 +19,8 @@ type AppRouter = Router interface AppRoutes { home: HomeRoute - about: AboutRoute + shop: ShopRoute + examples: ExamplesRoutes /** * @see Plugin.install of @vue/runtime-core */ @@ -32,8 +36,8 @@ interface AppRoutes { namespace AppRouter { let instance: AppRouter - export function setupRouter(router?: AppRouter): AppRouter { - instance = router ?? newInstance() + export function setupRouter(i18n: I18n, router?: AppRouter): AppRouter { + instance = router ?? newInstance(i18n) return instance } @@ -46,7 +50,17 @@ namespace AppRouter { return instance } - function newInstance(): AppRouter { + function newInstance(i18n: I18n): AppRouter { + //---------------------------------------------------------------------- + // + // Variables + // + //---------------------------------------------------------------------- + + const locale = computed(() => (i18n.global.locale as WritableComputedRef).value) + + const { loadI18nLocaleMessages } = useI18nUtils() + //---------------------------------------------------------------------- // // Properties @@ -56,11 +70,12 @@ namespace AppRouter { //-------------------------------------------------- // Set your routes - const routeInput: RouteInput = {} + const routeInput: BaseRouteInput = { locale } const routes = { home: HomeRoute.newWrapInstance(routeInput), - about: AboutRoute.newWrapInstance(routeInput), + shop: ShopRoute.newWrapInstance(routeInput), + examples: ExamplesRoutes.newWrapInstance(routeInput), install: (app: App) => { app.config.globalProperties.$routes = routes }, @@ -68,11 +83,14 @@ namespace AppRouter { const flattenRoutes: RawRoute[] = [ routes.home, - routes.about, + routes.shop, + routes.examples.abc, + routes.examples.miniatureProject, + routes.examples.routing, // fallback route Route.newWrapInstance({ routePath: `/:pathMatch(.*)*`, - redirect: `/home`, + redirect: `/${locale.value}/home`, }), ] @@ -81,8 +99,38 @@ namespace AppRouter { const router = Router.newWrapInstance({ routes, flattenRoutes, + beforeRouteUpdate: async (router, to, from, next) => { + const paramsLocale = to.params.locale as string + + // if `paramsLocale` is not in `SupportI18nLocales`, use current `locale` + if (!SupportI18nLocales.includes(paramsLocale)) { + next(`/${locale.value}`) + return false + } + + // load locale messages + await loadI18nLocaleMessages(paramsLocale) + + return true + }, }) + //---------------------------------------------------------------------- + // + // Events + // + //---------------------------------------------------------------------- + + watch( + () => locale.value, + async (newValue, oldValue) => { + // when a language switch occurs, refresh the current route to embed the switched language + // in the path. + const current = flattenRoutes.find(route => route.isCurrent.value) + current && (await current.refresh()) + } + ) + //---------------------------------------------------------------------- // // Result diff --git a/src/router/routes/examples.ts b/src/router/routes/examples.ts new file mode 100644 index 0000000..96258ac --- /dev/null +++ b/src/router/routes/examples.ts @@ -0,0 +1,295 @@ +import type { BaseRouteInput, WrapBaseRoute } from '@/router/base' +import type { Ref, UnwrapNestedRefs } from 'vue' +import { isImplemented, pickProps, removeEndSlash } from 'js-common-lib' +import { reactive, ref } from 'vue' +import { BaseRoute } from '@/router/base' +import type { DeepReadonly } from 'js-common-lib' +import type { LocationQueryValue } from 'vue-router' +import { pathToRegexp } from 'path-to-regexp' +import url from 'url' + +//========================================================================== +// +// ExamplesRoutes +// +//========================================================================== + +type ExamplesRoutes = UnwrapNestedRefs + +interface WrapExamplesRoutes { + readonly abc: WrapAbcRoute + readonly miniatureProject: WrapMiniatureProjectRoute + readonly routing: WrapRoutingRoute +} + +namespace ExamplesRoutes { + export function newWrapInstance(input: BaseRouteInput) { + const result = { + abc: AbcRoute.newWrapInstance(input), + miniatureProject: MiniatureProjectRoute.newWrapInstance(input), + routing: RoutingRoute.newWrapInstance(input), + } + + return isImplemented(result) + } +} + +//========================================================================== +// AbcRoute +//========================================================================== + +type AbcRoute = UnwrapNestedRefs + +interface WrapAbcRoute extends WrapBaseRoute { + readonly message: DeepReadonly +} + +interface AbcRouteMessage { + title?: string + body?: string +} + +namespace AbcRoute { + export function newWrapInstance(input: BaseRouteInput) { + //---------------------------------------------------------------------- + // + // Variables + // + //---------------------------------------------------------------------- + + const base = BaseRoute.newWrapInstance({ + routePath: `/:locale/examples/abc`, + component: () => import('@/pages/examples/abc'), + ...input, + }) + + const message = reactive({ + title: undefined, + body: undefined, + }) + + //---------------------------------------------------------------------- + // + // Methods + // + //---------------------------------------------------------------------- + + base.move.body = (async message => { + Object.assign(message || {}, pickProps(message || {}, ['title', 'body'])) + await base.router.value.push(toMovePath(message)) + }) + + const toMovePath = (base.toMovePath.body = (message => { + const query: { [key: string]: string } = {} + if (message?.title) { + query.title = message.title + } + if (message?.body) { + query.body = message.body + } + + return base.toPath({ + routePath: base.routePath.value, + query, + }) + })) + + //---------------------------------------------------------------------- + // + // Internal methods + // + //---------------------------------------------------------------------- + + // this method is called before the route is moved by routing + base.update.body = async route => { + await base.update.super(route) + + // set message object when moved to own route + if (base.isCurrent.value) { + message.title = route.query.title as string | undefined + message.body = route.query.body as string | undefined + } + // clear the message object if it is moved to a route that is not its own + else { + message.title = undefined + message.body = undefined + } + } + + //---------------------------------------------------------------------- + // + // Result + // + //---------------------------------------------------------------------- + + return { + ...base, + message, + } + } +} + +//========================================================================== +// MiniatureProjectRoute +//========================================================================== + +type MiniatureProjectRoute = UnwrapNestedRefs + +interface WrapMiniatureProjectRoute extends WrapBaseRoute {} + +namespace MiniatureProjectRoute { + export function newWrapInstance(input: BaseRouteInput) { + //---------------------------------------------------------------------- + // + // Variables + // + //---------------------------------------------------------------------- + + const base = BaseRoute.newWrapInstance({ + routePath: `/:locale/examples/miniature-project`, + component: () => import('@/pages/examples/miniature-project'), + ...input, + }) + + //---------------------------------------------------------------------- + // + // Result + // + //---------------------------------------------------------------------- + + return { + ...base, + } + } +} + +//========================================================================== +// RouteingExampleRoute +//========================================================================== + +type RoutingRoute = UnwrapNestedRefs + +interface WrapRoutingRoute extends WrapBaseRoute { + readonly page: Ref + parse(path_or_fullPath: string): { page: number } | undefined + replacePage(page: number): Promise +} + +namespace RoutingRoute { + export function newWrapInstance(input: BaseRouteInput) { + //---------------------------------------------------------------------- + // + // Variables + // + //---------------------------------------------------------------------- + + const base = BaseRoute.newWrapInstance({ + routePath: `/:locale/examples/routing`, + component: () => import('@/pages/examples/routing'), + ...input, + }) + + const page = ref(NaN) + + //---------------------------------------------------------------------- + // + // Methods + // + //---------------------------------------------------------------------- + + base.move.body = (async newPage => { + // set new move path as route + await base.router.value.push(toMovePath(newPage)) + }) + + const toMovePath = (base.toMovePath.body = (page => { + return base.toPath({ + routePath: base.routePath.value, + query: { page: page.toString() }, + }) + })) + + const parse: RoutingRoute['parse'] = path_or_fullPath => { + const parsedURL = url.parse(path_or_fullPath, true) + if (!parsedURL.pathname) return undefined + + const reg = pathToRegexp(base.routePath.value) + const regArray = reg.exec(parsedURL.pathname) + if (!regArray || regArray?.length < 2) return undefined + + return { + page: toPage(parsedURL.query.page), + } + } + + const replacePage: RoutingRoute['replacePage'] = async page => { + const nextPath = toMovePath(page) + const currentPath = removeEndSlash(base.router.value.currentRoute.value.fullPath) + if (currentPath === nextPath) return + + await base.router.value.replace(toMovePath(page)) + } + + //---------------------------------------------------------------------- + // + // Internal methods + // + //---------------------------------------------------------------------- + + base.update.body = async route => { + await base.update.super(route) + + if (base.isCurrent.value) { + page.value = toPage(route.query.page) + } else { + page.value = 0 + } + } + + function toPage( + pageString: string | string[] | LocationQueryValue | LocationQueryValue[] | undefined + ): number { + // if no page is specified in the query, the page number should be "1" + // Note: even if there is no page specification, the URL will be considered normal + if (pageString === undefined) return 1 + // if the page specified in the query is not a string, the page number should be "NaN" + // Note: determine that the URL is abnormal + if (typeof pageString !== 'string') return NaN + + // if a number is specified in the page string, it will be successfully parsed into a page + // number. if an invalid string other than a number is specified in the page string, the + // page number will be parsed to "NaN". + return parseInt(pageString) + } + + function isNumberString( + pageString: string | string[] | LocationQueryValue | LocationQueryValue[] | undefined + ): pageString is string { + if (!pageString || Array.isArray(pageString)) return false + return !isNaN(parseInt(pageString)) + } + + //---------------------------------------------------------------------- + // + // Result + // + //---------------------------------------------------------------------- + + const result = { + ...base, + page, + parse, + replacePage, + } + + return isImplemented(result) + } +} + +//========================================================================== +// +// Export +// +//========================================================================== + +export { ExamplesRoutes } diff --git a/src/router/routes/home.ts b/src/router/routes/home.ts index d28c0e8..47afd96 100644 --- a/src/router/routes/home.ts +++ b/src/router/routes/home.ts @@ -1,5 +1,5 @@ -import type { RouteInput, WrapRoute } from '@/router' -import { Route } from '@/router' +import type { BaseRouteInput, WrapBaseRoute } from '@/router/base' +import { BaseRoute } from '@/router/base' import type { UnwrapNestedRefs } from 'vue' import { isImplemented } from 'js-common-lib' @@ -7,21 +7,21 @@ import { isImplemented } from 'js-common-lib' // HomeRoute //========================================================================== -type HomeRoute = UnwrapNestedRefs +type HomeRoute = UnwrapNestedRefs -interface WrapAboutRoute extends WrapRoute {} +interface WrapHomeRoute extends WrapBaseRoute {} namespace HomeRoute { - export function newWrapInstance(input: RouteInput) { + export function newWrapInstance(input: BaseRouteInput) { //---------------------------------------------------------------------- // // Variables // //---------------------------------------------------------------------- - const base = Route.newWrapInstance({ - routePath: `/home`, - component: () => import('@/views/HomeView.vue'), + const base = BaseRoute.newWrapInstance({ + routePath: `/:locale/home`, + component: () => import('@/pages/home'), ...input, }) @@ -35,7 +35,7 @@ namespace HomeRoute { ...base, } - return isImplemented(result) + return isImplemented(result) } } diff --git a/src/router/routes/about.ts b/src/router/routes/shop.ts similarity index 62% rename from src/router/routes/about.ts rename to src/router/routes/shop.ts index 3086346..c7d3cc4 100644 --- a/src/router/routes/about.ts +++ b/src/router/routes/shop.ts @@ -1,27 +1,29 @@ -import type { RouteInput, WrapRoute } from '@/router' -import { Route } from '@/router' +import type { BaseRouteInput, WrapBaseRoute } from '@/router/base' +import { BaseRoute } from '@/router/base' import type { UnwrapNestedRefs } from 'vue' import { isImplemented } from 'js-common-lib' //========================================================================== -// AboutRoute +// +// ShopRoute +// //========================================================================== -type AboutRoute = UnwrapNestedRefs +type ShopRoute = UnwrapNestedRefs -interface WrapAboutRoute extends WrapRoute {} +interface WrapShopRoute extends WrapBaseRoute {} -namespace AboutRoute { - export function newWrapInstance(input: RouteInput) { +namespace ShopRoute { + export function newWrapInstance(input: BaseRouteInput) { //---------------------------------------------------------------------- // // Variables // //---------------------------------------------------------------------- - const base = Route.newWrapInstance({ - routePath: `/about`, - component: () => import('@/views/AboutView.vue'), + const base = BaseRoute.newWrapInstance({ + routePath: `/:locale/shop`, + component: () => import('@/pages/shop'), ...input, }) @@ -35,7 +37,7 @@ namespace AboutRoute { ...base, } - return isImplemented(result) + return isImplemented(result) } } @@ -45,4 +47,4 @@ namespace AboutRoute { // //========================================================================== -export { AboutRoute } +export { ShopRoute } diff --git a/src/tests/helpers/services/index.ts b/src/tests/helpers/services/index.ts index db27566..6b8c278 100644 --- a/src/tests/helpers/services/index.ts +++ b/src/tests/helpers/services/index.ts @@ -24,42 +24,40 @@ interface TestServices extends UnwrapNestedRefs { + const user = TestUsers.find(user => { + return user.email === email && user.password === password + }) + if (!user) { + throw new Error(i18n.t('signIn.signInError', { email: email })) + } - /** - * Mocking the sign-in process - */ - base.signIn.body = async (email, password) => { - const user = TestUsers.find(user => { - return user.email === email && user.password === password - }) - if (!user) { - throw new Error(i18n.t('signIn.signInError', { email: email })) - } - - const exists = Boolean(stores.user.get(user.id)) - exists ? stores.user.set(user) : stores.user.add(user) - User.populate(base.user.value, user) - base.isSignedIn.value = true + const exists = Boolean(stores.user.get(user.id)) + exists ? stores.user.set(user) : stores.user.add(user) + User.populate(base.user.value, user) + base.isSignedIn.value = true - return true - } + return true + } - return { - ...base, + return { + ...base, + } } } diff --git a/src/tests/unit/pages/examples/abc/ABCViewPC.spec.ts b/src/tests/unit/pages/examples/abc/ABCViewPC.spec.ts new file mode 100644 index 0000000..ddaf214 --- /dev/null +++ b/src/tests/unit/pages/examples/abc/ABCViewPC.spec.ts @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import AbcViewPC from '@/pages/examples/abc/ABCViewPC.vue' +import { TestUsers } from '@/services/test-data' +import { mount } from '@vue/test-utils' +import { nextTick } from 'vue' +import { useDialogs } from '@/dialogs' +import { useServiceDependencies } from '@/tests/helpers' + +vi.mock('@/router', () => { + return { + useRouter: vi.fn().mockImplementation(() => { + return { routes: { examples: { abc: { message: {} } } } } + }), + } +}) + +const signIn = { + open: vi.fn().mockImplementation(async () => { + const { services } = useServiceDependencies() + const taro = TestUsers[0] + await services.account.signIn(taro.email, taro.password) + }), +} +vi.mock('@/dialogs', () => { + return { + useDialogs: vi.fn().mockImplementation(() => { + return { signIn } + }), + } +}) + +describe('ABCViewPC.vue', () => { + beforeEach(() => { + useServiceDependencies() + }) + + // it('if signIn button is clicked', async () => { + // const wrapper = mount(AbcViewPC, { + // global: { + // stubs: { + // QCard: { template: '
' }, + // QBtn: { template: '
' }, + // QInput: { template: '
' }, + // // QBtn: true, + // // QInput: true, + // }, + // }, + // }) + // + // await wrapper.get('[data-testid="signInOrOutButton"]').trigger('click') + // + // const dialogs = useDialogs() + // expect(dialogs.signIn.open).toBeCalledTimes(1) + // + // const signedInEmail = wrapper.get('[data-testid="signedInEmail"]') + // expect(signedInEmail.text()).toBe(`Taro Yamada `) + // }) + + it('if a message is entered ', async () => { + const wrapper = mount(AbcViewPC, { + global: { + stubs: { + QCard: { template: '
' }, + QBtn: { template: '
' }, + QInput: { template: '
' }, + }, + }, + }) + + const dialogs = useDialogs() + expect(dialogs.signIn.open).toBeCalledTimes(0) + + const message = wrapper.vm.message + message.body = 'hello' + + await nextTick() + + expect(wrapper.get('[data-testid="reversedMessage"]').text()).toBe('olleh') + expect(wrapper.get('[data-testid="doubleReversedMessage"]').text()).toBe('hello') + expect(wrapper.get('[data-testid="watchEffectMessage"]').text()).toBe('hello') + }) +}) diff --git a/src/views/AboutView.vue b/src/views/AboutView.vue deleted file mode 100644 index b54a969..0000000 --- a/src/views/AboutView.vue +++ /dev/null @@ -1,52 +0,0 @@ - - - - - diff --git a/src/views/HomeView.vue b/src/views/HomeView.vue deleted file mode 100644 index 6555a64..0000000 --- a/src/views/HomeView.vue +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/vite.config.ts b/vite.config.ts index e37327f..c73f77f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -15,7 +15,12 @@ export default defineConfig({ quasar({ sassVariables: 'src/styles/quasar.scss', }), - vueI18n(), + + // TODO + // When build and run with the following specified, the text is not displayed, + // so please investigate. + // vueI18n({}), + vueJsx(), ],